Back to Blog
React NativeWebViewMobileIntegrationTypeScript

WebView Integration Patterns: Bridging React Native and React Web

Umut Korkmaz2025-04-0110 min read

Our mobile regulated app is built with React Native, but some features — complex data visualizations, document viewers, rich-text editors — are better served by React web. The challenge: embedding a React web app inside a React Native shell while maintaining security, performance, and a seamless user experience. After months of iteration, here's the architecture we settled on.

Why WebView in a Native App?

Before diving into the how, let me justify the why. We embed React web content for three reasons:

  1. Shared codebases: Our investment dashboard uses D3.js charts that work identically on web and mobile
  2. Rapid iteration: Web content can be updated without app store review cycles
  3. Specialist features: PDF rendering and document annotation libraries have better web implementations

The trade-off is complexity. You're essentially running two JavaScript runtimes that need to communicate securely.

HMAC-Signed Communication Protocol

The most critical aspect is the communication bridge between native and web. We use HMAC-SHA256 signed messages to prevent message injection attacks. If someone can inject JavaScript into the WebView (via a compromised CDN or XSS), unsigned messages would let them call native functionality.

typescript
// shared/messageProtocol.ts (used by both native and web)
import { HmacSHA256, enc } from 'crypto-js';

interface BridgeMessage {
  type: string;
  payload: any;
  timestamp: number;
  nonce: string;
  signature: string;
}

export function signMessage(
  type: string,
  payload: any,
  secret: string
): BridgeMessage {
  const timestamp = Date.now();
  const nonce = generateNonce();

  const dataToSign = JSON.stringify({ type, payload, timestamp, nonce });
  const signature = HmacSHA256(dataToSign, secret).toString(enc.Hex);

  return { type, payload, timestamp, nonce, signature };
}

export function verifyMessage(
  message: BridgeMessage,
  secret: string
): boolean {
  const { signature, ...rest } = message;
  const dataToVerify = JSON.stringify(rest);
  const expectedSignature = HmacSHA256(dataToVerify, secret).toString(enc.Hex);

  // Timing-safe comparison
  if (signature.length !== expectedSignature.length) return false;
  let result = 0;
  for (let i = 0; i < signature.length; i++) {
    result |= signature.charCodeAt(i) ^ expectedSignature.charCodeAt(i);
  }

  // Reject messages older than 30 seconds
  if (Date.now() - message.timestamp > 30000) return false;

  return result === 0;
}

The timing-safe comparison prevents timing attacks, and the 30-second window prevents replay attacks. The HMAC secret is derived per session and never exposed in the WebView's JavaScript context.

The WebViewPoolContext

Creating a WebView is expensive — it initializes a full browser engine. On lower-end Android devices, this takes 500-800ms. Our WebViewPoolContext pre-initializes WebViews so they're ready when needed:

typescript
// contexts/WebViewPoolContext.tsx
import React, { createContext, useContext, useRef, useCallback } from 'react';
import { WebView } from 'react-native-webview';

interface PooledWebView {
  id: string;
  ref: React.RefObject<WebView>;
  isAvailable: boolean;
  lastUsed: number;
}

interface WebViewPoolContextType {
  acquireWebView: (purpose: string) => PooledWebView | null;
  releaseWebView: (id: string) => void;
  getPoolStatus: () => PoolStatus;
}

const POOL_SIZE = 3;
const MAX_IDLE_TIME = 5 * 60 * 1000; // 5 minutes

export const WebViewPoolProvider: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const pool = useRef<PooledWebView[]>(
    Array.from({ length: POOL_SIZE }, (_, i) => ({
      id: `webview-pool-${i}`,
      ref: React.createRef<WebView>(),
      isAvailable: true,
      lastUsed: 0,
    }))
  );

  const acquireWebView = useCallback((purpose: string) => {
    const available = pool.current.find((wv) => wv.isAvailable);
    if (!available) {
      // All WebViews in use — check for stale ones
      const stale = pool.current.find(
        (wv) => Date.now() - wv.lastUsed > MAX_IDLE_TIME
      );
      if (stale) {
        stale.isAvailable = false;
        stale.lastUsed = Date.now();
        return stale;
      }
      return null;
    }

    available.isAvailable = false;
    available.lastUsed = Date.now();
    return available;
  }, []);

  const releaseWebView = useCallback((id: string) => {
    const webView = pool.current.find((wv) => wv.id === id);
    if (webView) {
      webView.isAvailable = true;
      // Reset the WebView state
      webView.ref.current?.injectJavaScript(
        'window.location.href = "about:blank"; true;'
      );
    }
  }, []);

  return (
    <WebViewPoolContext.Provider
      value={{ acquireWebView, releaseWebView, getPoolStatus }}
    >
      {children}
      {/* Render hidden pre-initialized WebViews */}
      {pool.current.map((wv) => (
        <WebView
          key={wv.id}
          ref={wv.ref}
          source={{ uri: 'about:blank' }}
          style={{ width: 0, height: 0, position: 'absolute' }}
          javaScriptEnabled
          onMessage={handlePoolMessage}
        />
      ))}
    </WebViewPoolContext.Provider>
  );
};

The pool pre-initializes 3 WebViews. Acquiring one is instant — no cold-start delay. When released, the WebView navigates to about:blank to clear state but keeps the browser engine warm.

The Native-to-Web Bridge

Our bridge component wraps the pooled WebView with the signing protocol:

typescript
// components/SecureWebView.tsx
interface SecureWebViewProps {
  url: string;
  onEvent: (event: WebViewEvent) => void;
}

export const SecureWebView: React.FC<SecureWebViewProps> = ({
  url,
  onEvent,
}) => {
  const { acquireWebView, releaseWebView } = useWebViewPool();
  const webViewRef = useRef<PooledWebView | null>(null);
  const sessionSecret = useRef(generateSessionSecret());

  useEffect(() => {
    const wv = acquireWebView('secure-content');
    webViewRef.current = wv;
    return () => {
      if (wv) releaseWebView(wv.id);
    };
  }, []);

  const sendToWeb = useCallback((type: string, payload: any) => {
    const message = signMessage(type, payload, sessionSecret.current);
    const js = `window.__NATIVE_BRIDGE__.receive(${JSON.stringify(message)}); true;`;
    webViewRef.current?.ref.current?.injectJavaScript(js);
  }, []);

  const handleMessage = useCallback((event: WebViewMessageEvent) => {
    try {
      const message: BridgeMessage = JSON.parse(event.nativeEvent.data);

      if (!verifyMessage(message, sessionSecret.current)) {
        console.warn('Invalid message signature from WebView');
        return;
      }

      switch (message.type) {
        case 'TOKEN_REQUEST':
          handleTokenRequest(message.payload);
          break;
        case 'NAVIGATION':
          onEvent({ type: 'navigate', data: message.payload });
          break;
        case 'ANALYTICS':
          trackEvent(message.payload);
          break;
        default:
          onEvent({ type: message.type, data: message.payload });
      }
    } catch (error) {
      console.error('Failed to parse WebView message:', error);
    }
  }, []);

  return (
    <WebView
      ref={webViewRef.current?.ref}
      source={{ uri: url }}
      onMessage={handleMessage}
      injectedJavaScriptBeforePageLoad={`
        window.__NATIVE_BRIDGE__ = {
          sessionId: '${sessionSecret.current.substring(0, 8)}',
          receive: function(message) {
            window.dispatchEvent(
              new CustomEvent('native-message', { detail: message })
            );
          }
        };
        true;
      `}
      originWhitelist={['https://*']}
      javaScriptEnabled
      domStorageEnabled={false}
      thirdPartyCookiesEnabled={false}
      sharedCookiesEnabled={false}
    />
  );
};

Note the security settings: domStorageEnabled={false} prevents local storage access, thirdPartyCookiesEnabled={false} prevents tracking, and originWhitelist restricts to HTTPS.

Web-Side Bridge Client

The React web app receives the bridge through the injected global:

typescript
// web/src/bridges/nativeBridge.ts
class NativeBridge {
  private listeners: Map<string, Set<(payload: any) => void>> = new Map();

  constructor() {
    window.addEventListener('native-message', ((event: CustomEvent) => {
      const message = event.detail as BridgeMessage;
      const handlers = this.listeners.get(message.type);
      if (handlers) {
        handlers.forEach((handler) => handler(message.payload));
      }
    }) as EventListener);
  }

  on(type: string, handler: (payload: any) => void): () => void {
    if (!this.listeners.has(type)) {
      this.listeners.set(type, new Set());
    }
    this.listeners.get(type)!.add(handler);

    return () => {
      this.listeners.get(type)?.delete(handler);
    };
  }

  send(type: string, payload: any): void {
    // The web side also signs messages
    const message = signMessage(type, payload, this.getSecret());
    window.ReactNativeWebView?.postMessage(JSON.stringify(message));
  }

  async requestToken(): Promise<string> {
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => reject(new Error('Token request timeout')), 5000);

      const unsub = this.on('TOKEN_RESPONSE', (payload) => {
        clearTimeout(timeout);
        unsub();
        resolve(payload.token);
      });

      this.send('TOKEN_REQUEST', {});
    });
  }

  private getSecret(): string {
    return (window as any).__BRIDGE_SECRET__;
  }
}

export const nativeBridge = new NativeBridge();

Token Handoff Pattern

The WebView needs authentication tokens but shouldn't store them. Every API call from the web content requests a fresh token from the native layer:

typescript
// web/src/api/client.ts
const apiClient = axios.create({ baseURL: API_URL });

apiClient.interceptors.request.use(async (config) => {
  if (isRunningInWebView()) {
    const token = await nativeBridge.requestToken();
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

This ensures tokens are never stored in the WebView's memory longer than necessary. The native TokenManager handles refresh logic, and the WebView gets a valid token on every request.

Performance Results

| Metric | Without Pool | With Pool | |--------|-------------|-----------| | WebView cold start (Android) | 650ms | 50ms | | WebView cold start (iOS) | 320ms | 30ms | | Message round-trip | 15ms | 12ms | | Memory overhead | On-demand | +45MB baseline |

The 45MB baseline memory cost of three pooled WebViews is the trade-off. On modern devices with 6-8GB RAM, it's acceptable. On budget devices, we reduce the pool to 1.

The WebView integration pattern isn't glamorous, but it lets us leverage the best of both worlds — native performance for core banking flows and web flexibility for specialized features. The HMAC signing adds minimal latency but significant security, and the pool eliminates the most common user complaint about WebView: the loading delay.