There's a particular kind of engineering pain that comes from maintaining two frontend codebases that do the same thing. You build a feature on web. Then you rebuild it on mobile. The designs diverge slightly because the native platform suggests different patterns. The business logic drifts because someone made an exception. Six months later, you have two products that look the same but behave differently, and you've doubled the surface area for bugs.
We ran into this hard at a healthtech company. Our web app (a React SPA) had months of product work baked into it: complex clinical workflows, form validation logic, real-time consultation screens. The mobile app (React Native) needed the same flows, and we couldn't afford to rebuild them.
The decision we made was this: don't rebuild the web app. Run it inside React Native instead.
This sounds obvious until you try it. Then it becomes a careful architectural problem. Here's exactly how we structured it, what broke, and what I'd do differently.
The Pattern: Dual Runtime System
The mental model is a host and a tenant. React Native is the host: it manages device capabilities, navigation, lifecycle, and authentication. The web app is the tenant: it handles UI rendering and business logic for the features it owns.
They don't share memory. They share a contract.
The bridge is where most hybrid approaches fail. If you let it become a direct function call layer (webApp.callNativeFunction()), you end up with tight coupling, unpredictable timing, and bugs that only manifest when the runtime state is slightly off. We designed it as an event bus with explicit contracts instead.
The Bridge Protocol
The browser's postMessage / onMessage API is the only reliable communication channel between a WebView and its host. It's string-based, async, and has no type safety by default. We built a thin typed layer over it.
Web → Native (commands)
On the web side, we publish an intent:
// packages/web-for-app/src/bridge.ts
type CommandType =
| "CLOSE_WEBVIEW"
| "START_VIDEO_CALL"
| "OPEN_DOCUMENT_PICKER"
| "REQUEST_TOKEN"
| "NAVIGATE";
interface BridgeCommand {
type: CommandType;
payload?: Record<string, unknown>;
requestId: string;
}
function dispatch(type: CommandType, payload?: Record<string, unknown>) {
if (!window.ReactNativeWebView) return;
const command: BridgeCommand = {
type,
payload: payload ?? {},
requestId: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
};
window.ReactNativeWebView.postMessage(JSON.stringify(command));
}
// Public API: the web app never calls dispatch() directly
export const bridge = {
closeWebView: () => dispatch("CLOSE_WEBVIEW"),
startVideoCall: (consultId: string) => dispatch("START_VIDEO_CALL", { consultId }),
openDocumentPicker: (accept: string[]) => dispatch("OPEN_DOCUMENT_PICKER", { accept }),
requestToken: () => dispatch("REQUEST_TOKEN"),
navigate: (screen: string, params?: object) =>
dispatch("NAVIGATE", { screen, params }),
};The important thing here is the requestId. Without it, you can't correlate async responses, which matters a lot for token requests and anything that returns data.
Native → Web (responses and pushes)
React Native can inject JavaScript directly into the WebView:
// apps/mobile/src/components/AppWebView.tsx
import { useRef, useCallback } from "react";
import { WebView, WebViewMessageEvent } from "react-native-webview";
import { useAuthStore } from "@/stores/auth";
import { useNavigation } from "@react-navigation/native";
interface IncomingCommand {
type: string;
payload: Record<string, unknown>;
requestId: string;
}
export function AppWebView({ url }: { url: string }) {
const webViewRef = useRef<WebView>(null);
const { getAccessToken, refreshToken } = useAuthStore();
const navigation = useNavigation();
// Push data back into the WebView by injecting JS
const respond = useCallback((requestId: string, data: unknown) => {
const js = `
window.__bridgeResponse(${JSON.stringify({ requestId, data })});
true;
`;
webViewRef.current?.injectJavaScript(js);
}, []);
const handleMessage = useCallback(
async (event: WebViewMessageEvent) => {
let cmd: IncomingCommand;
try {
cmd = JSON.parse(event.nativeEvent.data);
} catch {
return;
}
switch (cmd.type) {
case "CLOSE_WEBVIEW":
navigation.goBack();
break;
case "START_VIDEO_CALL":
navigation.navigate("VideoCall", { consultId: cmd.payload.consultId });
break;
case "OPEN_DOCUMENT_PICKER":
// launch native doc picker, then respond with the result
const doc = await launchDocumentPicker(cmd.payload.accept as string[]);
respond(cmd.requestId, doc);
break;
case "REQUEST_TOKEN":
const token = await getAccessToken();
respond(cmd.requestId, { token });
break;
case "NAVIGATE":
navigation.navigate(cmd.payload.screen as never, cmd.payload.params as never);
break;
}
},
[navigation, respond, getAccessToken]
);
return (
<WebView
ref={webViewRef}
source={{ uri: url }}
onMessage={handleMessage}
javaScriptEnabled
domStorageEnabled
/>
);
}Receiving responses in the web app
The web app needs a listener for the __bridgeResponse calls from native:
// packages/web-for-app/src/bridge.ts (continued)
type ResponseHandler = (data: unknown) => void;
const pendingRequests = new Map<string, ResponseHandler>();
// Called by native via injectJavaScript
window.__bridgeResponse = ({ requestId, data }: { requestId: string; data: unknown }) => {
const handler = pendingRequests.get(requestId);
if (handler) {
handler(data);
pendingRequests.delete(requestId);
}
};
// Extended dispatch that returns a promise for request-response patterns
function dispatchAndAwait<T>(
type: CommandType,
payload?: Record<string, unknown>,
timeoutMs = 5000
): Promise<T> {
return new Promise((resolve, reject) => {
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const timer = setTimeout(() => {
pendingRequests.delete(requestId);
reject(new Error(`Bridge timeout: ${type}`));
}, timeoutMs);
pendingRequests.set(requestId, (data) => {
clearTimeout(timer);
resolve(data as T);
});
window.ReactNativeWebView?.postMessage(JSON.stringify({ type, payload, requestId }));
});
}
export const bridge = {
// fire-and-forget
closeWebView: () => dispatch("CLOSE_WEBVIEW"),
navigate: (screen: string, params?: object) =>
dispatch("NAVIGATE", { screen, params }),
// request-response
requestToken: () =>
dispatchAndAwait<{ token: string; expiresIn: number }>("REQUEST_TOKEN"),
openDocumentPicker: (accept: string[]) =>
dispatchAndAwait<{ uri: string; name: string }>("OPEN_DOCUMENT_PICKER", { accept }),
};Put together, a single request flows like this: a promise on the web side, an injected callback on the way back, correlated by requestId:
Typing the injected globals
None of these globals exist in the standard DOM types, so without augmentation every access is a TypeScript error (or an any you'll regret). Declare them once:
// packages/web-for-app/src/global.d.ts
declare global {
interface Window {
ReactNativeWebView?: { postMessage: (msg: string) => void };
__NATIVE_RUNTIME__?: boolean;
__APP_VERSION__?: string;
__CAPABILITIES__?: Record<string, "native" | "web" | "disabled">;
__bridgeResponse?: (res: { requestId: string; data: unknown }) => void;
__bridgeReady?: () => void;
}
}
export {};Runtime Detection
The web app needs to know where it's running (browser, WebView, or development storybook), because behaviour differs. Don't gate on navigator.userAgent (fragile, lying browsers). Use an explicit handshake instead.
React Native injects a global before the page loads:
// apps/mobile/src/components/AppWebView.tsx
const INJECTED_JS = `
window.__NATIVE_RUNTIME__ = true;
window.__APP_VERSION__ = "${appVersion}";
window.__CAPABILITIES__ = ${JSON.stringify(capabilities)};
true;
`;
<WebView
injectedJavaScriptBeforeContentLoaded={INJECTED_JS}
// ...
/>The web app reads this immediately:
// packages/web-for-app/src/runtime.ts
type CapabilityMode = "native" | "web" | "disabled";
export const runtime = {
isNative: () => window.__NATIVE_RUNTIME__ === true,
appVersion: () => window.__APP_VERSION__ as string | undefined,
// Where should this feature run? Defaults to "web" outside the native shell.
mode: (cap: string): CapabilityMode =>
window.__CAPABILITIES__?.[cap] ?? "web",
// Is it available at all? (Note: don't use truthiness on the raw value:
// "disabled" is a truthy string, so `!!capabilities[cap]` would lie.)
isEnabled: (cap: string) => runtime.mode(cap) !== "disabled",
};
// Usage in a component
function ConsultationScreen() {
const startCall = () => {
if (runtime.isNative()) {
bridge.startVideoCall(consultId); // delegate to native
} else {
openJitsiInBrowser(consultId); // handle in web
}
};
}Why not check window.ReactNativeWebView directly?
You can, but ReactNativeWebView appears after the initial JS execution in some RN versions; there's a race window. The injected global via injectedJavaScriptBeforeContentLoaded is guaranteed to exist before any of your app code runs.
Auth: Delegated Identity
This is the most fragile part of the whole system if you get it wrong.
The natural instinct is to share a token: maybe the web app reads it from localStorage or a cookie. Don't. The moment you do that, you have two auth managers running in the same session. Refresh logic fires in two places. Token invalidation goes to one and not the other. You'll spend a week debugging a session expiry that only happens when the user hasn't opened the app in exactly 45 minutes.
React Native owns the token lifecycle. The web app asks for tokens.
// packages/web-for-app/src/auth.ts
let cachedToken: { value: string; expiresAt: number } | null = null;
export async function getAuthToken(): Promise<string> {
if (!runtime.isNative()) {
// In browser: manage tokens normally (localStorage, cookies, etc.)
return localAuthManager.getToken();
}
// In WebView: ask the host
const now = Date.now();
if (cachedToken && cachedToken.expiresAt > now + 30_000) {
return cachedToken.value;
}
const { token, expiresIn } = await bridge.requestToken();
cachedToken = { value: token, expiresAt: now + expiresIn * 1000 };
return token;
}The 30-second buffer (now + 30_000) is important: you want to refresh before expiry, not at expiry, because the request to get a new token takes time.
Two-layer defense for token refresh
On the native side, the REQUEST_TOKEN handler shouldn't just return the stored token blindly:
// apps/mobile/src/stores/auth.ts
case "REQUEST_TOKEN": {
let token = getStoredToken();
// If close to expiry, proactively refresh
if (isTokenExpiringSoon(token)) {
token = await refreshAccessToken();
storeToken(token);
}
respond(cmd.requestId, {
token: token.accessToken,
expiresIn: token.expiresIn,
});
break;
}This gives you a two-layer defense: the web app caches the token and avoids calling back too often, and native refreshes proactively when needed. Between the two layers, you rarely hit a situation where a user gets a 401 in the middle of a clinical workflow.
Hardening the Bridge
This is the section that's easy to skip and expensive to skip. The bridge is a privileged channel: through it, anything running in the WebView can ask native to hand over an access token, open the document picker, or navigate. postMessage has no built-in notion of "who is calling." So your security model has to be explicit.
The threat is concrete. Your WebView loads app.yourcompany.com, but a stray <a target="_blank">, an OAuth redirect, an injected third-party script, or an honest XSS can all end up executing JavaScript inside that same WebView, with full access to window.ReactNativeWebView.postMessage. If native blindly trusts every message, you've handed an attacker a token-minting API.
Three layers of defense:
1. Pin the WebView to your origins
Don't let the WebView navigate anywhere it likes. Whitelist origins and intercept every navigation; push anything off-domain to the system browser instead.
// apps/mobile/src/components/AppWebView.tsx
const ALLOWED_ORIGINS = ["https://app.yourcompany.com"];
<WebView
source={{ uri: url }}
originWhitelist={ALLOWED_ORIGINS}
onShouldStartLoadWithRequest={(req) => {
const ok = ALLOWED_ORIGINS.some((o) => req.url.startsWith(o));
if (!ok) {
Linking.openURL(req.url); // open externally, not in the privileged WebView
return false;
}
return true;
}}
// ...
/>2. Validate every inbound message: never trust JSON.parse
JSON.parse(event.nativeEvent.data) gives you an any. A malformed or malicious payload shouldn't reach your handlers. Validate against a schema at the boundary. We used Zod:
// packages/bridge-contracts/src/schema.ts
import { z } from "zod";
const command = z.discriminatedUnion("type", [
z.object({ type: z.literal("START_VIDEO_CALL"), requestId: z.string(),
payload: z.object({ consultId: z.string() }) }),
z.object({ type: z.literal("REQUEST_TOKEN"), requestId: z.string(),
payload: z.object({}).optional() }),
z.object({ type: z.literal("OPEN_DOCUMENT_PICKER"), requestId: z.string(),
payload: z.object({ accept: z.array(z.string()) }) }),
// ...one entry per command
]);
export function parseCommand(raw: string) {
const result = command.safeParse(JSON.parse(raw));
return result.success ? result.data : null; // drop anything malformed
}The handler becomes: parse → if null, ignore and log → otherwise cmd.payload is now fully typed (no as never casts) and structurally guaranteed. This is the same schema package both sides import, so it doubles as the contract that prevents drift (more on that later).
3. Make injectJavaScript injection-proof
injectJavaScript runs a raw string as code. The instinct is string interpolation, and that's exactly how you get an injection bug if any interpolated value is attacker-influenced. Always serialize with JSON.stringify, and patch the one gotcha it doesn't cover:
// JSON.stringify is *almost* safe, but U+2028 / U+2029 are valid in JSON
// strings yet historically break JS string literals when eval'd.
function safeStringify(data: unknown) {
return JSON.stringify(data)
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
const respond = (requestId: string, data: unknown) => {
const js = `window.__bridgeResponse(${safeStringify({ requestId, data })}); true;`;
webViewRef.current?.injectJavaScript(js);
};The broader principle: the bridge exposes intents, not capabilities. START_VIDEO_CALL is an intent: native decides what that means and can refuse. If you'd instead exposed something like EXEC_NATIVE_METHOD with a method name in the payload, you'd have built a remote-code-execution primitive. Keep the surface area a closed, enumerable set of verbs.
Control Plane: Configuration-Driven Behaviour
The architecture above gets you a working hybrid. The control plane is what makes it scale.
The idea: instead of deciding at build time which features are "native" or "web", make it a runtime decision driven by a configuration object.
// Injected by native before page load
window.__CAPABILITIES__ = {
videoCall: "native", // use native video SDK
chat: "native", // use native chat UI
documentPicker: "native", // use native file picker
imageViewer: "web", // web handles this fine
ehrForm: "web", // complex web-only form
};
window.__ALLOWED_ROUTES__ = ["/ehr", "/consult", "/patient/:id"];
window.__APP_VERSION__ = "5.2.1";These values come from your backend (via a feature-flag service, or a config endpoint the mobile app fetches on startup). This lets you:
1. Feature delegation. Swap "web" to "native" for video calling when the native SDK is ready, without a web deployment.
2. Killswitch per version. If a web feature is broken in app version 5.1.x, set "ehrForm": "disabled" for that version. The web app reads runtime.isEnabled("ehrForm") and shows a fallback.
3. Progressive rollout. Roll out a new native implementation to 10% of users by having the backend return "native" only for those users.
// packages/web-for-app/src/features/VideoCall.tsx
function startConsultation(consultId: string) {
const capability = runtime.mode("videoCall");
switch (capability) {
case "native":
bridge.startVideoCall(consultId);
break;
case "web":
openWebRTCSession(consultId);
break;
case "disabled":
showUnavailableMessage();
break;
default:
openWebRTCSession(consultId); // safe default for browser
}
}Navigation: One Brain Only
The failure mode I've seen in every hybrid app that didn't think about this: you end up with two navigation managers running simultaneously. React Navigation on the native side, and the browser's history API on the web side. The back button does unpredictable things. The user presses back and exits the web view entirely, losing their form state.
React Native owns navigation. The web app never pushes to window.history.
// packages/web-for-app/src/navigation.ts
// We intercept all navigation intents in the web app and delegate upward
export function navigateTo(screen: string, params?: object) {
if (runtime.isNative()) {
bridge.navigate(screen, params);
return;
}
// In browser, use normal routing
router.push(screen);
}
// Intercept the browser back button in WebView context
if (runtime.isNative()) {
window.history.pushState = () => {}; // no-op
window.history.replaceState = () => {}; // no-op
window.addEventListener("popstate", (e) => {
e.preventDefault();
bridge.navigate("BACK");
});
}On the React Native side, handle the hardware back button (Android) and swipe-back gesture consistently:
// apps/mobile/src/components/AppWebView.tsx
// Intercept Android hardware back button
useEffect(() => {
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
navigation.goBack();
return true; // prevents default behavior
});
return () => subscription.remove();
}, [navigation]);Analytics: One Stream
The other failure mode is dual analytics. Two separate SDKs sending events with slightly different schemas. Your data team ends up with button_click and buttonClick in the same Amplitude project and you can never trust any funnel.
Both runtimes send events through the same pipeline:
// packages/web-for-app/src/analytics.ts
interface TrackEvent {
name: string;
properties?: Record<string, unknown>;
}
export function track(event: TrackEvent) {
if (runtime.isNative()) {
// Delegate to native analytics SDK (Amplitude, Mixpanel, etc.)
bridge.trackEvent(event);
} else {
// Send directly from web in browser context
analytics.track(event.name, event.properties);
}
}On the native side, the event handler just calls the SDK:
case "TRACK_EVENT": {
const { name, properties } = cmd.payload;
Analytics.track(name, {
...properties,
source: "webview", // lets you filter in analytics
appVersion: AppVersion, // native knows this
});
break;
}The source: "webview" property lets you segment by runtime without creating separate events.
The npm Package: web-for-app
The bridge, runtime detection, and auth delegation logic are entirely framework-agnostic. We extracted them into an internal package. If you're building something similar, here's how the package structure looks:
packages/web-for-app/
├── src/
│ ├── bridge.ts # postMessage abstraction
│ ├── runtime.ts # environment detection
│ ├── auth.ts # delegated token management
│ ├── analytics.ts # unified event tracking
│ └── index.ts # public API
├── package.json
└── tsconfig.json
The public API is deliberately small:
// packages/web-for-app/src/index.ts
export { bridge } from "./bridge";
export { runtime } from "./runtime";
export { getAuthToken } from "./auth";
export { track } from "./analytics";Usage from any web app or component:
import { bridge, runtime, getAuthToken, track } from "@yourorg/web-for-app";
// Works in browser (no-ops gracefully) and in WebView
const token = await getAuthToken();
track({ name: "consultation_started", properties: { consultId } });
if (runtime.can("videoCall")) {
bridge.startVideoCall(consultId);
}The package doesn't care whether it's inside React, Vue, vanilla JS, or Flutter's WebView implementation, as long as the host injects the __NATIVE_RUNTIME__ global and the __bridgeResponse callback, it works.
Flutter note: Flutter's
flutter_inappwebviewpackage exposeswindow.flutter_inappwebview.callHandler()instead ofwindow.ReactNativeWebView.postMessage. The bridge implementation changes; the contract and the runtime detection layer stay the same.
Things That Actually Broke
1. Events fired before the bridge was ready.
The web app initialised and immediately called bridge.requestToken() before the WebView had fully mounted and the onMessage handler was wired. The message was sent into a void.
Fix: the web app waits for an explicit BRIDGE_READY push from native before sending any commands.
// Native injects this after onLoad fires
const READY_JS = `window.__bridgeReady?.(); true;`;
webViewRef.current?.injectJavaScript(READY_JS);
// Web app
let bridgeReady = false;
const pendingOnReady: (() => void)[] = [];
window.__bridgeReady = () => {
bridgeReady = true;
pendingOnReady.forEach((fn) => fn());
pendingOnReady.length = 0;
};
function whenReady(fn: () => void) {
if (bridgeReady) fn();
else pendingOnReady.push(fn);
}
// All bridge calls go through whenReady
export const bridge = {
requestToken: () => whenReady(() => dispatchAndAwait("REQUEST_TOKEN")),
// ...
};2. Token race on initial load.
The web app would request a token, receive it, and immediately make an authenticated API call, but the API call would use a stale token from the previous session that had already been revoked. Native's token store hadn't fully rehydrated yet.
Fix: native doesn't respond to REQUEST_TOKEN until the auth store is in a stable state. We added an authReady promise to the store:
// Native: only respond after auth is ready
case "REQUEST_TOKEN": {
await authStore.ready; // waits for rehydration from SecureStore
const token = await getAccessToken();
respond(cmd.requestId, { token });
break;
}3. Contract drift.
After six months, the native START_VIDEO_CALL handler expected { consultId } but the web app was sending { consultationId }. Someone renamed the field on the web side without updating the native handler. We didn't catch it until a QA tester tried to start a video call.
Fix: a shared TypeScript contract package that both sides import:
// packages/bridge-contracts/src/index.ts
export interface BridgeCommands {
START_VIDEO_CALL: { consultId: string };
CLOSE_WEBVIEW: Record<string, never>;
REQUEST_TOKEN: Record<string, never>;
OPEN_DOCUMENT_PICKER: { accept: string[] };
NAVIGATE: { screen: string; params?: object };
TRACK_EVENT: { name: string; properties?: Record<string, unknown> };
}
// Both native and web import this. TypeScript enforces the contract.Tradeoffs
Be honest with yourself about what you're trading.
| What you gain | What you give up |
|---|---|
| One codebase for complex UI/logic | Raw native performance for those features |
| Instant web updates, no app store wait | Debugging across two runtimes is harder |
| Full web tooling (DevTools, hot reload) | Serialization overhead on every bridge call |
| Browser feature access (Web APIs) | Native feel and gesture fidelity |
| Shared types between web and mobile | Team needs discipline to maintain contracts |
The serialization overhead is real but rarely the bottleneck. A postMessage round-trip with a small JSON payload takes 1–3ms on modern devices. For token requests and navigation, that's invisible. For high-frequency events (tracking every scroll position), batch them.
The debugging story is the real cost. When something goes wrong, the call stack spans two runtimes. console.log in the WebView doesn't appear in Xcode or Android Studio unless you enable remote debugging, which changes V8 behaviour on older RN versions. Build a DEBUG_BRIDGE flag that logs every message in both directions; you'll thank yourself at 11pm on a Friday.
When to Use This
Use it when:
- You have substantial web product work that would be expensive to rebuild natively
- Your team is stronger on web than native
- The features don't require native performance (no real-time video rendering, no complex animations)
- You need to ship the same feature on both platforms without doubling the work
Avoid it when:
- The core product experience is about native feel (a camera app, a maps app, a game)
- Your team can't maintain the contract discipline; this architecture has teeth if the bridge drifts
- You're building performance-critical features (real-time audio processing, heavy animations)
- You only have one or two small features to share; it's not worth the scaffolding overhead
What I'd Do Differently
The contract drift problem cost us the most time. If I were starting again, I'd set up the shared bridge-contracts package on day one and enforce it with CI: a test that imports the contracts on both sides and asserts they're in sync, so you can't ship a web change that breaks the native handler.
I'd also invest earlier in a bridge inspector: a dev-mode overlay that shows every postMessage in flight, colour-coded by direction and resolution status. Rolling console.log statements through two runtimes is survivable for the first month. After that, it becomes a context-switch tax that slows down every debugging session.
The architecture itself held. Two years and three product lines running on it, the core abstraction never needed to change. The contracts did (features were added, payloads evolved), but the bridge protocol, the runtime detection, and the auth delegation pattern stayed exactly as designed.
That's usually the sign you got the abstraction right.