We can change our paywall in five minutes. New price, different offer, fresh A/B test — no release, no App Store review, no waiting for users to update. For a product where the subscription is the revenue, that speed isn't a convenience. It's a superpower. And it's cheap to get: the paywall lives on the web, and the app renders it inside a WebView.
We pay for that superpower. We just didn't learn the price right away.
We learned it the day a user opened the paywall, changed their mind, tapped Back — and got stuck. The button was there. It pressed. It went nowhere. And behind that paywall sat access to their own data. There was exactly one way out: kill the app.
The telling part is that the Back button had worked fine for six months. We never touched it. Someone in a neighbouring part of the app wrapped the paywall in its own navigation stack, and the same code started leading into a dead end.
By then four bugs were already living in our tracker, and we were fixing them one at a time:
- the user gets trapped on the paywall;
- payment goes through, the screen doesn't update;
- "download file" sometimes doesn't download;
- buttons on web and native screens don't match.
For months we treated them as four separate ailments. Until we saw they were one bug — and an architectural one. All four live on the same line: where web meets native. Every hybrid app has one of these. A fault line. It looks like solid ground, right up until the plates move.
Why everyone reaches for a WebView — and why that isn't stupid
The first reaction to this architecture is usually "you shouldn't have dragged web into the app, you should have built it natively." That answer is lazy, and it's wrong.
A paywall isn't a screen. It's a revenue surface: price, offer mix, screen order, copy, a dozen A/B tests a quarter. And one question decides everything — how fast can you ship a new price? Native means weeks: build, App Store review, waiting for people to update. Some of them never will, and they'll keep seeing last year's offer for years. A paywall inside a WebView means minutes, and everyone sees it at once, including the person still on a build from a year ago. This isn't a cheap hack in place of native. It's a deliberate trade: native feel in exchange for release independence. For a paywall it's the right trade, and we'd make it again.
Now look at what you bought along with that independence. Two parts of an app shipping on different clocks are no longer one screen inside another. They're two systems. Two plates, each moving on its own. Where they touch, a fault line runs — and it sits there quietly until the day the plates have shifted far enough that the Back button stops working.
We didn't add a web page to our app. We built a distributed system — and didn't notice.
The fault line: give it a name
A fault line is the boundary between two parts of a system that deploy independently of each other.
It isn't a disease in itself. Two parts shipping separately is normal architecture, and with a careful contract they coexist beautifully — two microservices with contract tests deploy independently and stay healthy. The pathology isn't the fault line. It's the undesigned fault line. So the ending of this piece won't ask you to fill yours in, only to describe it and version it.
While web and native shipped in one release, there was no boundary. The Back button and its handler changed in the same commit and rolled out together. But the moment web got its own release clock, a line appeared between them. Two plates, pressure building where they meet, flat ground on the surface. Until it shakes.
It always shakes where the two sides agreed on something implicitly. Web sends NAV_BACK and assumes there's somewhere to go back to. Native reads abBucket out of the request body and assumes web put in exactly the string it knows how to parse. Every one of those assumptions is a clause in a contract nobody wrote down. And an unwritten contract does the only thing it knows how to do: it drifts. Web renames a parameter; native still reads the old key. Web adds a new action; the native handler doesn't know it and swallows it silently. Nobody broke anything on purpose. The plates moved by one release, and the contract came apart.
Here's the trap that keeps all of this invisible. If I walked into a review and proposed connecting two microservices over a protocol with no schema, no versions, no error handling, passing whole objects through a URL string, I'd be turned away inside a minute. But that is precisely what happens at the web↔native boundary, and it goes unnoticed — because it doesn't look like a distributed system. It looks like a web page inside an app.
That disguise, the costume of an ordinary page, is the most expensive part of the fault line. It hides the fact that you're already paying the distributed-systems tax, just without a single one of its disciplines: no contracts, no versioning, no idempotency, no error handling at the boundary. Take the costume off and you'll find the line isn't one line. There are four.
Anatomy: not one line, but four
Channel 1. native → web: the POST body. We hand data to the WebView not through the query string and not through postMessage, but as a POST request, with everything in the body:
const source = {
uri: `${WEB_HOST}/${route}`,
method: 'POST',
body: JSON.stringify({
plans: JSON.stringify(plans),
abBucket,
os,
lang,
showBackButton: !launchedFromTab,
// …three dozen more fields
}),
};
Close to forty fields. Some of them strings inside strings, courtesy of a double JSON.stringify. No types, no version. And note showBackButton — there's the future trap: native decides here, at load time, on web's behalf, whether to render a Back button. First clause of the contract, and it's already implicit.
Channel 2. native → web: injected JS. The second way to pass something into web is a completely different transport: mix JavaScript straight into the page.
const injectedJavaScript = `
document.documentElement.style
.setProperty('--app-inset-top', '${insets.top}px');
`;
Native generates code as a string and injects it into someone else's runtime. This is the tamest of the four transports — a couple of CSS variables in one direction, rarely breaks. I'm showing it for scale: even a triviality like this is a separate, second boundary with its own semantics, unconnected to the first.
Channel 3. web → native: deeplink commands. Going the other way, web pokes native through a deeplink carrying ?action=.... On the receiving side, a hand-rolled dispatcher:
Linking.addEventListener('url', ({ url }) => {
const { query } = parseUrl(url);
switch (query.action) {
case NAV_BACK: return goBack();
case CHECKOUT_DONE: return finishCheckout(query);
case SHARE_TEXT: return shareText(query.text);
case OPEN_EXTERNAL: return Linking.openURL(query.link);
// …~9 more branches
}
});
Why a deeplink rather than postMessage? It has an honest upside: the same URL channel works from inside the WebView and from outside it — from a push, from an external link — which makes it convenient as a single entry point. But it's still RPC with a URL chosen as the transport. Thirteen commands, zero versions, fire-and-forget: web throws a command and never learns whether it worked. And one of those branches JSON.parses an entire object straight out of the query string, with all the length limits and encoding joys that implies.
Channel 4. the result: past every channel. You'd reasonably expect a command's result to come back the way it went out. It doesn't. The outcome of a purchase lands in a different component, through the billing SDK's listener:
// not where the command was sent from
billing.onEntitlementsChanged((state) => {
const ok = hasEntitlement(state);
debouncedRedirect(ok); // 500 ms, to "calm down" the race
});
The command left through channel 3, the answer arrived through channel 4 — asynchronously, in another file, and it still has to be smoothed over with a debounce. Request and response live on different boundaries and know nothing about each other.
So: one "fault line" on the diagram is four independent transports — POST body, injected JS, deeplink RPC, and an out-of-band listener. Four semantics, four ways to break, zero shared contract. You thought you had a web page inside an app. What you have is four under-specified protocols taped into one screen.
Why the fault line moves: anatomy of the trap
Back to the trapped user. The question "can the user go back right now?" is answered in three different places in our app, and each one rests on its own assumption.
Place one — channel 1, at load time. Native puts showBackButton: !launchedFromTab into the request body. Web decides whether to render the button before anything is painted, based on a fact only native knows.
Place two — channel 3, on tap. The user taps, web sends NAV_BACK. Native catches it and does exactly one thing:
if (action === NAV_BACK) navigation.goBack();
Blindly. Without ever asking whether there's anywhere to go back to from the stack it's currently in.
Place three — the post-purchase path. Here, tellingly, the code is already smart:
if (canPop(navigation)) navigation.goBack();
else navigation.reset({ routes: [{ name: ROOT }] });
This time native checks its own state before navigating. One intent — "take the user back" — implemented in two places: once with a context check, once blind.
goBack() worked. Then a neighbouring feature moved the paywall into its own stack, and the assumption behind place two became false. The plate shifted. goBack() from a foreign stack went nowhere.The root cause matters more than the bug: NAV_BACK isn't data. It's a command that depends on state living entirely on the native side. Web says "one step back" in terms of history; native hears goBack() in terms of a stack; and nobody anywhere guaranteed those are the same thing. Web was commanding blind — issuing orders about a context it couldn't see. The context changed by one release, and it broke.
Now put on the same glasses and look at the other three bugs. Same DNA.
- Payment went through, screen didn't update— the command left through channel 3, the answer was supposed to arrive through channel 4; the plates fell out of sync in time.
- "Download file" sometimes doesn't download— another blind command: web assumes native is in the right state with the right permissions. Sometimes it isn't.
- Buttons don't match— both sides render their own version of "the same" button; the visual contract went unwritten too, and it drifts too.
Nobody wrote any of these bugs. Each one was a correct assumption that went stale when the second plate moved in its own release. That's what makes a fault line treacherous: it doesn't fail when you write the code, it fails when something nearby changes — and the stack trace points at the wrong culprit.
How to tell you're already paying
Before the earthquake, a fault line is asymptomatic: everything's green, the tests pass. You have to diagnose it the way a seismologist would, from tremors rather than a collapsed bridge.
From the user's side the tremors are quiet: the button is there, it presses, nothing happens — not an error, just silence; an action works every other time. In the code and in the process they're easier to spot:
- web triggers stateful native calls(
goBack(),reset()) without checking what state native is actually in; - command handlers with no version and no error branch— an unknown command falls silently into the void;
- growing parsing of parameters out of URLs, and a growing count of
if (os === …); - bugs somewhere in between, with no usable stack trace, that QA can't reproduce step by step and that surface after a release which never touched that code.
There should probably be a before/after table with percentages here. There won't be, and that's part of the point. The cost of a fault line doesn't sit in latency graphs or error counters. It sits in support tickets, in bugs with no culprit, and in team velocity leaking away quietly. We never measured the share of dead-end paywall sessions — we learned about it from a trickle of tickets saying "I can't close this screen." We never measured the time it takes to add a new command across the boundary — we just felt that every such change somehow required touching both sides, testing both, and waiting for a native release. The dispatcher kept growing, and each new command ate a little more of the very OTA speed the whole thing was built for.
That asymptomatic quality is exactly why it took us so long to see the obvious: four different bugs, one architectural problem.
But there's a question sharper than any checklist. Walk up to your own web↔native boundary and ask: who owns its contract? Where is the list of every command, every field, every assumption, with a version and an owner? If the answer is "it just sort of ended up this way" or "John knows, he wrote it," you have your diagnosis. The fault line has no owner, which means it drifts. The question isn't whether it breaks, but on which release.
The framework: design the contract
We're not removing the fault line. It buys the OTA superpower this was all for. We're giving it what it never had: an owner and a contract. One shift in perspective solves 80% of the problem:
Treat the web↔native boundary as an API between two services. Because that's exactly what it is.
What follows is six principles, every one of which you'd apply by reflex to two microservices, and skipped because "it's just a web page." An honest disclaimer first: none of them is done in our production yet. There's exactly one branch — the state check before navigating on the post-purchase path — that accidentally turned out to be the seed of the third principle and showed us how the rest should work. Everything below is the blueprint we're moving toward now that we've seen the cost, not a victory report.
1. The contract has an owner and one home. Not deeplink parsing smeared across ten files, but a single bridge module on each side and one list: every command, every field, every assumption. This is the direct answer to "who owns the boundary" — that module does.
2. The contract is explicit and versioned. A typed message schema plus a version field. An unknown command or a version mismatch stops being a silent fall into the void and becomes a logged, handled event. The plates go on moving at their own pace, but now the drift is visible instead of fatal.
3. A command is an intent, not an action. This is the cure for the trap. Web shouldn't be ordering native to goBack() — that's someone else's mechanism, and web knows nothing about its state. Web declares a goal; native decides how to reach it:
// Before: web drives the mechanism blind
send('deeplink://?action=NAV_BACK'); // web
if (action === NAV_BACK) navigation.goBack(); // native
// How it should be: web declares intent, native owns execution
bridge.send({ type: 'CLOSE_PAYWALL', v: 1 }); // web
onClosePaywall() { // native
canPop(nav) ? nav.goBack() : nav.reset({ routes: [{ name: ROOT }] });
}
We already have that canPop branch, on the post-purchase path. Strictly speaking it isn't principle 3 yet: checking state before goBack() is still an action, just a less blind one. But it's what revealed the right idea — navigation is decided by the side that can see the state. What's left is to carry it up to an intent. Meanwhile the blind NAV_BACK, the one from the trap, hasn't been touched at all.
4. Every command gets an answer. No fire-and-forget. Correlation id, timeout, an explicit error branch — and the answer comes back through the same channel the command went out on, instead of surfacing asynchronously in some other component. This is what fixes "payment went through, screen didn't update."
5. Respect the runtime lifecycle. Instead of debounces and // Do it once!, an honest "web is ready" handshake, a queue until readiness, and idempotency by id against duplicates. Races don't get "calmed down" by a timeout; they get designed out.
6. Don't build half-web/half-native screens. A screen is either entirely web or entirely native. The fault line should run between screens, not through one — otherwise "the same" button is doomed to drift apart.
Not one of the six is about WebView. All six are about how grown-up systems talk across a boundary: schema, versions, intents, acknowledgements, readiness, ownership. You aren't giving up the OTA superpower. You're just no longer paying for it blind.
Where to start on Monday: migrating without a big bang
The framework is easy to read as "throw out your dispatcher and write a proper one." On a live product you can't: rewriting everything at once is precisely the earthquake we're trying to avoid. The plan is different — the strangler pattern, one command at a time.
Start with the command channel; that's where it hurts most. The answer channel is next, and it's cured by the correlation id from principle 4. Channels 1 and 2 are less urgent, but channel 1 will come back into play — the whole trick hangs on it.
- Schema alongside the old parsing, not instead of it.A new bridge module with a typed, versioned schema goes up in parallel with the old-
switch. Nothing gets deleted. - Dual-read on native.An incoming message tries the new module first; if the command isn't in the schema, it falls through transparently to the old parser.
- Migrate one command at a time.Add the command to the schema, route it through the new module. First candidate is the most painful one:-
NAV_BACK→-CLOSE_PAYWALL, with execution owned by native.
And here's the trap a naive strangler doesn't see.
Native doesn't update for everyone, and it doesn't update at once. Web can start sending { type: 'CLOSE_PAYWALL', v: 1 } tomorrow — and land in a build from a year ago that only knows ?action=NAV_BACK. Dual-read doesn't save you here: the old build physically doesn't contain the new module. Which adds two things the naive plan is missing:
- The old parser can't be deleted until old app versions die out.That's not a sprint, it's a quarter or two, and it's a product decision rather than an engineering one. "Done" arrives not when you commit, but when telemetry shows no old versions left in production.
- Since web runs ahead of native, web needs a way to learn what native on the other side understands.The place for that already exists: channel 1, the POST body — the same one that started this story with an implicit-
showBackButton. Have native put a-bridgeVersionin there at load time, and let web- pick the format: if the build understands-CLOSE_PAYWALL, send it; if not, fall back to legacy.
There's the loop closing: the channel where an implicit assumption once sprang the trap becomes the point of explicit version negotiation. The drift gets treated where it was born.
Just don't kid yourself about that fallback. The legacy branch is exactly the trap this article opened with. A fallback isn't a cure here, it's controlled degradation. Pick as your legacy branch the command that carries the fewest assumptions — if one of the thirteen is an explicit "go to home," it's safer than a blind "step back" — and log every time it fires, so you can see how much traffic is still sitting on the old protocol.
Now let's say out loud what all of this implies, which is the hardest argument for a contract in this whole piece.
Builds already in the wild cannot be fixed. The native code on a user's phone is frozen. However many correct canPop + reset branches you write today, they don't exist for the person on a build from a year ago. The only lever that reaches old builds is web.
Which means the cost of a mistake at the fault line is asymmetric. A mistake on web you roll back in five minutes, using the very superpower this was all for. A mistake in a native handler you never roll back: it lives as long as the build lives. Hence a rule that isn't among the six principles but follows from them: whatever you freeze into native should be a general capability, not a product decision. "Knowing how to close the paywall, figuring out its own stack" is a capability — it won't expire when the product changes its flow. "Calling goBack(), because the paywall sits in a stack with history" is a hardcoded assumption about the product, i.e. a decision. Capabilities are safe to freeze. Decisions aren't: the product will change, and the build on the user's phone will stay.
It also explains why principle 3 is the most important one. "Intent instead of action" isn't an aesthetic preference. It's how you leave as few frozen decisions as possible on the side of the fault line you can't replay.
The fault line is also a trust boundary
So far we've talked about reliability. But a fault line has a second face: it's a security perimeter as well. And the same costume hides it — "we're only loading our own page."
The page is yours; the runtime isn't. A WebView is a full browser: redirects, script injection, cache, third-party content one link away. Anything that crosses the boundary now lives by browser rules rather than yours. So the boundary deserves to be designed not just as a data channel but as a trust perimeter, with the same questions you'd ask of any outward-facing API.
Some of them are answered by flags: originWhitelist, sharedCookiesEnabled={false}, allowFileAccess={false}. Worth setting — but note that they're the same story as canPop + reset: point patches, not a designed trust model. A flag answers "what is forbidden right here." A model answers "what crosses this boundary at all, and with what privileges" — and that's a question this kind of boundary usually never gets asked.
There's also an asymmetry that's easy to forget: a command channel is by its nature triggered from outside — by another link, by another app. It's an entrance open to more than just your web. Which means the first rule of any external boundary applies: anything arriving from the other side is untrusted input until proven otherwise. A command sent by your page and a command forged from outside look identical on a channel like that; the only thing that can tell them apart is whatever you put into the contract yourself.
The same principles as the framework, viewed through trust:
- Least privilege across the boundary— exactly as much data and authority as web genuinely needs, and not one field more. Not a session token if a narrow, short-lived one will do.
- Untrusted input— validate the parameters, authenticate the sender; a sensitive action shouldn't execute on the strength of a message having arrived.
- An explicit trust zone— decide deliberately where it ends, before the WebView or inside it. "It ended up this way" is also a decision, just not one you made.
A fault line is two boundaries in one: reliability and trust. And an unwritten contract fails both for the same reason — because it pretends it isn't there.
This isn't about WebView
The easiest way to read this article is "WebView is evil, write native." That conclusion is wrong. A fault line doesn't appear because of WebView. It appears the moment two parts of a system start deploying independently.
Micro-frontends talking through postMessage in an iframe — same fault line. Two microservices without contract tests — same fault line. A producer changing an event's shape while the consumer has no idea — same fault line. Any native↔JS bridge. Any client and server, really. Everywhere two sides evolve on their own clocks and agree implicitly, the same tax accrues.
Because this is a general law rather than a React Native detail: any undesigned boundary between independently evolving systems accumulates cost — and presents the bill at the least convenient moment.
That user didn't get trapped on the paywall because of a Back button. They got trapped because a boundary ran between the two halves of the app, and nobody had given it a name, an owner, or a version. The button was merely the place where the fault line finally shook.
So find your fault line before it finds you. Don't remove it — if it buys you a superpower like OTA, it's worth keeping. Just stop paying for it blindly: give it a name, assign an owner, write the contract down, and put a version on it. The fault line isn't going anywhere. But earthquakes can be moved from "inevitable" to "scheduled."