Skip to content
Ayhan Sipahi Ayhan Sipahi

React Native WebView Communication: postMessage Bridge Patterns

WebView-to-native communication patterns: message passing, service integration, and a type-safe request-response bridge with working code.

WebView-to-native communication in a mobile micro frontend fails in three concrete ways: messages arrive out of order, TypeScript types diverge between the web and native sides, and JavaScript exceptions inside a WebView surface no stack trace in native crash reporters. Without a structured bridge layer these failures compound; a race condition in message passing can silently swallow payment confirmations or auth tokens.

The default worth reaching for is a typed request-response bridge: every message carries an id, every request carries a timeout, and both sides compile against one shared TypeScript contract. A payment flow split between native authentication and a WebView checkout is where the gap usually shows, because the race only appears on devices with slower JavaScript engines. Everything after the protocol definition (service integration, chunking, batching, platform quirks) hangs off that one contract.

Mobile Micro Frontend Series

This is Part 2 of the mobile micro frontends series:

Haven’t read Part 1? Start there for architecture fundamentals.

Ready for production? Jump to Part 3 for optimization strategies.

The Communication Challenge

WebView communication seems simple at first. You have postMessage on the web side and onMessage on the native side. What could go wrong?

Everything, as it turns out:

  • Messages are strings, so you lose type safety
  • No built-in request/response pattern
  • No delivery guarantees or acknowledgments
  • Different behavior between iOS and Android
  • No way to handle timeouts or retries
  • Performance degradation with large payloads

Alternative Communication Approaches

Four approaches compete with a typed postMessage bridge. Each one solves part of the list above, and each one charges for it somewhere else.

Option 1: Re.Pack Module Federation Communication

Re.Pack provides a different communication model through Module Federation. Instead of message passing, it allows direct module sharing between native and web contexts.

Setup:

// Re.Pack Module Federation setup
// webpack.config.js (host app)
const { ModuleFederationPlugin } = require('@module-federation/nextjs-mf');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        payment: 'payment@http://localhost:3001/remoteEntry.js',
        booking: 'booking@http://localhost:3002/remoteEntry.js',
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true },
        // Shared communication layer
        '@shared/bridge': { singleton: true }
      }
    })
  ]
};

// Shared bridge module
// @shared/bridge/index.ts
export interface NativeBridge {
  getAuthToken(): Promise<string>;
  openCamera(): Promise<string>;
  navigate(screen: string, params?: any): void;
}

// In micro frontend
import { NativeBridge } from '@shared/bridge';

const PaymentComponent = () => {
  const handlePayment = async () => {
    // Direct function call instead of message passing
    const token = await NativeBridge.getAuthToken();
    const photo = await NativeBridge.openCamera();

    // Process payment with native data
    await processPayment(token, photo);
  };

  return <button onClick={handlePayment}>Pay</button>;
};

What it costs:

  • Complexity: Every team has to adopt Module Federation at once
  • Type safety: Shared modules have to live in a separate package
  • Versioning: Module version conflicts are hard to debug
  • Performance: Initial bundle size grows noticeably
  • Debugging: Stack traces span multiple contexts

When to use Re.Pack Module Federation:

  • You’re building a true super app with multiple teams
  • All teams can coordinate on shared modules
  • You need direct function calls between contexts
  • Performance overhead is acceptable

Option 2: Rspack Module Federation

Rspack’s Module Federation is another option for communication:

// rspack.config.mjs
export default {
  entry: './src/index.tsx',
  plugins: [
    new ModuleFederationPlugin({
      name: 'micro-frontend',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App.tsx',
        './Bridge': './src/bridge.ts'
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true }
      }
    })
  ]
};

// Bridge implementation
// src/bridge.ts
interface BridgeTransport {
  send(action: string, payload?: unknown): Promise<unknown>;
}

export class RspackBridge {
  private static instance: RspackBridge;

  // Module Federation shares the module, not a channel. The host still
  // has to inject a transport (postMessage, WebSocket, custom scheme).
  private transport?: BridgeTransport;

  static getInstance(): RspackBridge {
    if (!RspackBridge.instance) {
      RspackBridge.instance = new RspackBridge();
    }
    return RspackBridge.instance;
  }

  setTransport(transport: BridgeTransport): void {
    this.transport = transport;
  }

  async request<T>(action: string, payload?: unknown): Promise<T> {
    const transport = this.transport;
    if (!transport) {
      throw new Error('RspackBridge: no transport configured');
    }

    return new Promise<T>((resolve, reject) => {
      const timeout = setTimeout(() => {
        reject(new Error(`Request ${action} timed out`));
      }, 10000);

      transport.send(action, payload)
        .then((result) => {
          clearTimeout(timeout);
          resolve(result as T);
        })
        .catch((error: unknown) => {
          clearTimeout(timeout);
          reject(error);
        });
    });
  }
}

What it costs:

  • React Native compatibility: React Native support is limited
  • Ecosystem maturity: Debugging tools and examples are still thin
  • Team adoption: Significant retraining for teams on webpack
  • Production stability: Newer than webpack-based solutions

When to use Rspack Module Federation:

  • You’re building web-only micro frontends
  • Build performance is critical
  • You can work with a rapidly evolving ecosystem
  • Your teams are comfortable with Rust-based tooling

Option 3: Web Workers + SharedArrayBuffer

For high-performance communication, Web Workers with SharedArrayBuffer are worth evaluating:

// High-performance communication using SharedArrayBuffer
class SharedArrayBridge {
  private sharedBuffer: SharedArrayBuffer;
  private int32Array: Int32Array;
  private messageQueue: ArrayBuffer;

  constructor() {
    this.sharedBuffer = new SharedArrayBuffer(1024);
    this.int32Array = new Int32Array(this.sharedBuffer);
    this.messageQueue = new ArrayBuffer(8192);
  }

  async request<T>(action: string, payload: any): Promise<T> {
    const messageId = this.generateId();

    // Write to shared buffer
    const encoder = new TextEncoder();
    const message = JSON.stringify({ id: messageId, action, payload });
    const bytes = encoder.encode(message);

    // Copy to shared buffer
    const uint8Array = new Uint8Array(this.sharedBuffer);
    uint8Array.set(bytes, 0);

    // Signal native side
    Atomics.notify(this.int32Array, 0);

    // Wait for response
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        reject(new Error('Request timed out'));
      }, 10000);

      // Poll for response
      const checkResponse = () => {
        const responseBytes = new Uint8Array(this.sharedBuffer, 512, 512);
        const responseText = new TextDecoder().decode(responseBytes);

        try {
          const response = JSON.parse(responseText);
          if (response.id === messageId) {
            clearTimeout(timeout);
            resolve(response.data);
          } else {
            setTimeout(checkResponse, 10);
          }
        } catch (error) {
          setTimeout(checkResponse, 10);
        }
      };

      checkResponse();
    });
  }
}

What it costs:

  • Browser support: SharedArrayBuffer requires COOP and COEP headers
  • Complexity: Much more complex to implement and debug
  • Security: Requires careful memory management
  • Platform differences: iOS WebView has different SharedArrayBuffer behavior

When to use SharedArrayBuffer:

  • You need extremely high-performance communication
  • You’re targeting modern browsers only
  • You can handle the complexity
  • Performance is more important than simplicity

Option 4: WebSocket Bridge

For real-time communication, WebSockets are a candidate:

// WebSocket-based bridge
class WebSocketBridge {
  private ws: WebSocket;
  private pendingRequests = new Map<string, {
    resolve: (value: any) => void;
    reject: (error: any) => void;
  }>();

  constructor(url: string) {
    this.ws = new WebSocket(url);
    this.ws.onmessage = this.handleMessage.bind(this);
  }

  async request<T>(action: string, payload: any): Promise<T> {
    const id = this.generateId();

    return new Promise((resolve, reject) => {
      this.pendingRequests.set(id, { resolve, reject });

      this.ws.send(JSON.stringify({
        id,
        action,
        payload,
        timestamp: Date.now()
      }));
    });
  }

  private handleMessage(event: MessageEvent) {
    const message = JSON.parse(event.data);
    const pending = this.pendingRequests.get(message.id);

    if (pending) {
      this.pendingRequests.delete(message.id);

      if (message.success) {
        pending.resolve(message.data);
      } else {
        pending.reject(new Error(message.error));
      }
    }
  }
}

What it costs:

  • Network dependency: Requires a network connection
  • Latency: Additional network hop
  • Complexity: The WebSocket lifecycle has to be managed
  • Security: Additional attack surface

When to use WebSocket bridge:

  • You need real-time communication
  • Network latency is acceptable
  • You’re building a distributed system
  • You need bi-directional streaming

Building a Robust Message Protocol

After several iterations, the following protocol resolves these issues:

// Shared types between native and web
interface BridgeMessage<T = unknown> {
  id: string;
  type: MessageType;
  action: string;
  payload: T;
  timestamp: number;
  version: string;
}

enum MessageType {
  REQUEST = 'REQUEST',
  RESPONSE = 'RESPONSE',
  EVENT = 'EVENT',
  ERROR = 'ERROR'
}

interface BridgeResponse<T = unknown> {
  id: string;
  success: boolean;
  data?: T;
  error?: {
    code: string;
    message: string;
    details?: unknown;
  };
}

Native Side Implementation

A production bridge implementation on the React Native side:

import { WebView } from 'react-native-webview';
import { EventEmitter } from 'events';

class NativeWebViewBridge extends EventEmitter {
  private webViewRef: React.RefObject<WebView>;
  private pendingRequests = new Map<string, {
    resolve: (value: any) => void;
    reject: (error: any) => void;
    timeout: NodeJS.Timeout;
  }>();
  private messageQueue: BridgeMessage[] = [];
  private isReady = false;

  constructor(webViewRef: React.RefObject<WebView>) {
    super();
    this.webViewRef = webViewRef;
  }

  // Send a request and wait for response
  async request<TRequest, TResponse>(
    action: string,
    payload: TRequest,
    timeoutMs = 10000
  ): Promise<TResponse> {
    const id = this.generateId();
    const message: BridgeMessage<TRequest> = {
      id,
      type: MessageType.REQUEST,
      action,
      payload,
      timestamp: Date.now(),
      version: '1.0'
    };

    return new Promise((resolve, reject) => {
      // Set up timeout
      const timeout = setTimeout(() => {
        this.pendingRequests.delete(id);
        reject(new Error(`Request ${action} timed out after ${timeoutMs}ms`));
      }, timeoutMs);

      // Store pending request
      this.pendingRequests.set(id, { resolve, reject, timeout });

      // Send message
      this.sendMessage(message);
    });
  }

  // Send a one-way event to the WebView. EventEmitter's own emit() stays
  // free for local dispatch to native handlers.
  sendEvent(action: string, payload?: unknown): void {
    const message: BridgeMessage = {
      id: this.generateId(),
      type: MessageType.EVENT,
      action,
      payload,
      timestamp: Date.now(),
      version: '1.0'
    };

    this.sendMessage(message);
  }

  // Reply to a request that came from the WebView
  sendResponse(
    id: string,
    success: boolean,
    data?: unknown,
    error?: BridgeResponse['error']
  ): void {
    this.sendMessage({
      id,
      success,
      data,
      error,
      type: MessageType.RESPONSE,
      action: 'response',
      payload: data,
      timestamp: Date.now(),
      version: '1.0'
    } as BridgeMessage & BridgeResponse);
  }

  protected sendMessage(message: BridgeMessage): void {
    if (!this.isReady) {
      // Queue messages until WebView is ready
      this.messageQueue.push(message);
      return;
    }

    const serialized = JSON.stringify(message);

    // postMessage lands on the WebView's window 'message' listener
    this.webViewRef.current?.postMessage(serialized);

    // Log for debugging
    if (__DEV__) {
      console.log(`[Bridge] Sent: ${message.action}`, message);
    }
  }

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

      switch (message.type) {
        case MessageType.RESPONSE:
          this.handleResponse(message as BridgeResponse);
          break;

        case MessageType.REQUEST:
          this.handleRequest(message);
          break;

        case MessageType.EVENT:
          this.handleEvent(message);
          break;

        case MessageType.ERROR:
          this.handleError(message);
          break;
      }
    } catch (error) {
      console.error('[Bridge] Failed to parse message:', error);
    }
  }

  private handleResponse(response: BridgeResponse): void {
    const pending = this.pendingRequests.get(response.id);
    if (!pending) return;

    clearTimeout(pending.timeout);
    this.pendingRequests.delete(response.id);

    if (response.success) {
      pending.resolve(response.data);
    } else {
      pending.reject(new Error(response.error?.message || 'Unknown error'));
    }
  }

  private handleRequest(message: BridgeMessage): void {
    // Emit event for native handlers to process
    this.emit(`request:${message.action}`, message);
  }

  private handleEvent(message: BridgeMessage): void {
    this.emit(`event:${message.action}`, message.payload);
  }

  private handleError(message: BridgeMessage): void {
    // Not 'error': EventEmitter throws on an unhandled 'error' event
    this.emit('bridge:error', message);
  }

  protected generateId(): string {
    return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
  }

  // Called when WebView signals it's ready
  onBridgeReady(): void {
    this.isReady = true;

    // Flush queued messages
    while (this.messageQueue.length > 0) {
      const message = this.messageQueue.shift();
      if (message) this.sendMessage(message);
    }
  }
}

Web Side Implementation

The web side needs to handle messages and provide a similar API:

// WebViewBridge.ts - injected into WebView
class WebViewBridge {
  private handlers = new Map<string, (payload: any) => Promise<any>>();
  private eventListeners = new Map<string, Set<(payload: any) => void>>();

  constructor() {
    // Listen for messages from native
    window.addEventListener('message', this.handleMessage.bind(this));

    // Override window.ReactNativeWebView for Android
    if (!window.ReactNativeWebView) {
      window.ReactNativeWebView = {
        postMessage: (message: string) => {
          window.postMessage(message, '*');
        }
      };
    }

    // Signal bridge is ready
    this.emit('BRIDGE_READY');
  }

  // Register request handler
  handle<TRequest, TResponse>(
    action: string,
    handler: (payload: TRequest) => Promise<TResponse>
  ): void {
    this.handlers.set(action, handler);
  }

  // Send request to native
  async request<TRequest, TResponse>(
    action: string,
    payload: TRequest
  ): Promise<TResponse> {
    const id = this.generateId();
    const message: BridgeMessage<TRequest> = {
      id,
      type: MessageType.REQUEST,
      action,
      payload,
      timestamp: Date.now(),
      version: '1.0'
    };

    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        reject(new Error(`Request ${action} timed out`));
      }, 10000);

      const responseHandler = (event: MessageEvent) => {
        try {
          const response: BridgeMessage = JSON.parse(event.data);

          if (response.type === MessageType.RESPONSE && response.id === id) {
            clearTimeout(timeout);
            window.removeEventListener('message', responseHandler);

            if ((response as BridgeResponse).success) {
              resolve((response as BridgeResponse).data);
            } else {
              reject(new Error((response as BridgeResponse).error?.message));
            }
          }
        } catch (error) {
          // Ignore parsing errors from other messages
        }
      };

      window.addEventListener('message', responseHandler);
      this.sendMessage(message);
    });
  }

  // Send event to native
  emit(action: string, payload?: unknown): void {
    const message: BridgeMessage = {
      id: this.generateId(),
      type: MessageType.EVENT,
      action,
      payload,
      timestamp: Date.now(),
      version: '1.0'
    };

    this.sendMessage(message);
  }

  // Subscribe to events from native
  on(action: string, listener: (payload: any) => void): () => void {
    if (!this.eventListeners.has(action)) {
      this.eventListeners.set(action, new Set());
    }

    this.eventListeners.get(action)!.add(listener);

    // Return unsubscribe function
    return () => {
      this.eventListeners.get(action)?.delete(listener);
    };
  }

  private async handleMessage(event: MessageEvent): Promise<void> {
    try {
      const message: BridgeMessage = JSON.parse(event.data);

      if (message.type === MessageType.REQUEST) {
        await this.handleRequest(message);
      } else if (message.type === MessageType.EVENT) {
        this.handleEvent(message);
      }
    } catch (error) {
      // Ignore non-bridge messages
    }
  }

  private async handleRequest(message: BridgeMessage): Promise<void> {
    const handler = this.handlers.get(message.action);

    if (!handler) {
      this.sendResponse(message.id, false, null, {
        code: 'HANDLER_NOT_FOUND',
        message: `No handler registered for action: ${message.action}`
      });
      return;
    }

    try {
      const result = await handler(message.payload);
      this.sendResponse(message.id, true, result);
    } catch (error) {
      this.sendResponse(message.id, false, null, {
        code: 'HANDLER_ERROR',
        message: error instanceof Error ? error.message : 'Unknown error',
        details: error
      });
    }
  }

  private handleEvent(message: BridgeMessage): void {
    const listeners = this.eventListeners.get(message.action);
    if (listeners) {
      listeners.forEach(listener => {
        try {
          listener(message.payload);
        } catch (error) {
          console.error(`Event listener error for ${message.action}:`, error);
        }
      });
    }
  }

  private sendMessage(message: BridgeMessage): void {
    const serialized = JSON.stringify(message);

    if (window.ReactNativeWebView?.postMessage) {
      window.ReactNativeWebView.postMessage(serialized);
    } else {
      window.parent.postMessage(serialized, '*');
    }
  }

  private sendResponse(
    id: string,
    success: boolean,
    data?: unknown,
    error?: any
  ): void {
    const response: BridgeResponse = {
      id,
      success,
      data,
      error
    };

    this.sendMessage({
      ...response,
      type: MessageType.RESPONSE,
      action: 'response',
      payload: data,
      timestamp: Date.now(),
      version: '1.0'
    });
  }

  private generateId(): string {
    return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
  }
}

// Initialize bridge globally
declare global {
  interface Window {
    bridge: WebViewBridge;
    ReactNativeWebView?: {
      postMessage: (message: string) => void;
    };
  }
}

window.bridge = new WebViewBridge();

Type-Safe Communication

Type safety across the bridge is achievable with a shared types file used by both native and web:

// Shared types file (used by both native and web)
export interface BridgeAPI {
  // Authentication
  'auth.getToken': {
    request: void;
    response: { token: string; expiresAt: number };
  };

  'auth.refreshToken': {
    request: { currentToken: string };
    response: { token: string; expiresAt: number };
  };

  // Navigation
  'navigation.navigate': {
    request: { screen: string; params?: Record<string, any> };
    response: void;
  };

  'navigation.goBack': {
    request: void;
    response: boolean;
  };

  // Native features
  'camera.takePhoto': {
    request: {
      quality?: number;
      allowEdit?: boolean;
    };
    response: {
      uri: string;
      width: number;
      height: number;
    };
  };

  'biometrics.authenticate': {
    request: { reason: string };
    response: { success: boolean };
  };

  // Analytics
  'analytics.track': {
    request: {
      event: string;
      properties?: Record<string, any>;
    };
    response: void;
  };
}

// Type-safe bridge wrapper
export class TypedBridge {
  constructor(private bridge: WebViewBridge | NativeWebViewBridge) {}

  async request<K extends keyof BridgeAPI>(
    action: K,
    payload: BridgeAPI[K]['request']
  ): Promise<BridgeAPI[K]['response']> {
    return this.bridge.request(action, payload);
  }

  on<K extends keyof BridgeAPI>(
    action: K,
    handler: (payload: BridgeAPI[K]['request']) => void
  ): () => void {
    return this.bridge.on(action, handler);
  }
}

Usage becomes completely type-safe:

// In WebView
const bridge = new TypedBridge(window.bridge);

// TypeScript knows the exact request/response types
const { token } = await bridge.request('auth.getToken', undefined);
const photo = await bridge.request('camera.takePhoto', { quality: 0.8 });

// Wrong payload shape fails at compile time
// bridge.request('auth.getToken', { wrong: 'param' }); // TypeScript error

Service Integration Patterns

Various services integrate through the bridge with the following patterns:

Authentication Service

// Native side
class AuthBridgeHandler {
  constructor(
    private bridge: NativeWebViewBridge,
    private authService: AuthService
  ) {
    this.setupHandlers();
  }

  private setupHandlers(): void {
    this.bridge.on('request:auth.getToken', async (message) => {
      try {
        const token = await this.authService.getAccessToken();

        if (!token) {
          throw new Error('No active session');
        }

        this.bridge.sendResponse(message.id, true, {
          token,
          expiresAt: this.authService.getTokenExpiry()
        });
      } catch (error) {
        this.bridge.sendResponse(message.id, false, null, {
          code: 'AUTH_ERROR',
          message: error.message
        });
      }
    });

    this.bridge.on('request:auth.refreshToken', async (message) => {
      try {
        const newToken = await this.authService.refreshToken();

        this.bridge.sendResponse(message.id, true, {
          token: newToken,
          expiresAt: this.authService.getTokenExpiry()
        });
      } catch (error) {
        // If refresh fails, force re-login
        this.bridge.sendEvent('auth.sessionExpired');

        this.bridge.sendResponse(message.id, false, null, {
          code: 'REFRESH_FAILED',
          message: 'Session expired'
        });
      }
    });
  }
}

// Web side - Auto-refreshing fetch wrapper
class AuthenticatedFetch {
  constructor(private bridge: TypedBridge) {}

  async fetch(url: string, options: RequestInit = {}): Promise<Response> {
    let token = await this.getValidToken();

    const response = await fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${token}`
      }
    });

    // Retry with refreshed token if unauthorized
    if (response.status === 401) {
      token = await this.refreshToken();

      return fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          'Authorization': `Bearer ${token}`
        }
      });
    }

    return response;
  }

  private async getValidToken(): Promise<string> {
    const cached = this.getCachedToken();

    if (cached && cached.expiresAt > Date.now() + 60000) {
      return cached.token;
    }

    return this.refreshToken();
  }

  private async refreshToken(): Promise<string> {
    const { token, expiresAt } = await this.bridge.request(
      'auth.refreshToken',
      { currentToken: this.getCachedToken()?.token ?? '' }
    );

    this.cacheToken(token, expiresAt);
    return token;
  }

  private cacheToken(token: string, expiresAt: number): void {
    sessionStorage.setItem('bridge_token', JSON.stringify({
      token,
      expiresAt
    }));
  }

  private getCachedToken(): { token: string; expiresAt: number } | null {
    const cached = sessionStorage.getItem('bridge_token');
    return cached ? JSON.parse(cached) : null;
  }
}

Native Feature Access

Native features are exposed to WebViews with the following pattern:

// Camera integration
class CameraBridgeHandler {
  constructor(
    private bridge: NativeWebViewBridge,
    private imagePicker: ImagePicker
  ) {
    this.bridge.on('request:camera.takePhoto', async (message) => {
      try {
        const { quality = 0.8, allowEdit = false } = message.payload || {};

        const result = await this.imagePicker.launchCamera({
          mediaType: 'photo',
          quality,
          allowsEditing: allowEdit,
          // Important: base64 for WebView compatibility
          includeBase64: true
        });

        if (result.didCancel) {
          throw new Error('User cancelled');
        }

        if (result.errorMessage) {
          throw new Error(result.errorMessage);
        }

        const asset = result.assets?.[0];
        if (!asset) {
          throw new Error('No image selected');
        }

        // Convert to data URI for WebView
        const dataUri = `data:${asset.type};base64,${asset.base64}`;

        this.bridge.sendResponse(message.id, true, {
          uri: dataUri,
          width: asset.width!,
          height: asset.height!
        });
      } catch (error) {
        this.bridge.sendResponse(message.id, false, null, {
          code: 'CAMERA_ERROR',
          message: error.message
        });
      }
    });
  }
}

// Web side usage
async function uploadProfilePhoto() {
  try {
    const photo = await bridge.request('camera.takePhoto', {
      quality: 0.9,
      allowEdit: true
    });

    // Convert data URI to blob for upload
    const blob = await dataURItoBlob(photo.uri);

    // Upload using authenticated fetch
    const formData = new FormData();
    formData.append('photo', blob);

    const response = await authenticatedFetch.fetch('/api/profile/photo', {
      method: 'POST',
      body: formData
    });

    return response.json();
  } catch (error) {
    if (error.message === 'User cancelled') {
      // Handle cancellation
      return null;
    }
    throw error;
  }
}

Performance Optimization

Two bottlenecks show up in almost every bridge implementation:

Large Payload Handling

Sending large payloads (images, documents) through postMessage is slow and can freeze the UI. The solution is chunking:

class ChunkedMessageHandler {
  private chunks = new Map<string, {
    chunks: string[];
    receivedCount: number;
    totalChunks: number;
  }>();

  // Split large messages into chunks
  sendChunked(message: BridgeMessage, chunkSize = 50000): void {
    const serialized = JSON.stringify(message);

    if (serialized.length <= chunkSize) {
      // Small enough to send directly
      this.send(serialized);
      return;
    }

    // Split into chunks
    const chunks: string[] = [];
    for (let i = 0; i < serialized.length; i += chunkSize) {
      chunks.push(serialized.slice(i, i + chunkSize));
    }

    const chunkId = this.generateId();

    // Send each chunk
    chunks.forEach((chunk, index) => {
      this.send(JSON.stringify({
        type: 'CHUNK',
        chunkId,
        chunkIndex: index,
        totalChunks: chunks.length,
        data: chunk
      }));
    });
  }

  handleChunk(message: any): BridgeMessage | null {
    const { chunkId, chunkIndex, totalChunks, data } = message;

    if (!this.chunks.has(chunkId)) {
      this.chunks.set(chunkId, {
        chunks: new Array(totalChunks),
        receivedCount: 0,
        totalChunks
      });
    }

    const chunkData = this.chunks.get(chunkId)!;
    chunkData.chunks[chunkIndex] = data;
    chunkData.receivedCount++;

    // Check if all chunks received
    if (chunkData.receivedCount === chunkData.totalChunks) {
      const complete = chunkData.chunks.join('');
      this.chunks.delete(chunkId);

      try {
        return JSON.parse(complete);
      } catch (error) {
        console.error('Failed to parse chunked message:', error);
        return null;
      }
    }

    return null;
  }
}

Message Batching

High-frequency events like analytics do not need their own round trip. Batching keeps them off the critical path:

class BatchedBridge extends NativeWebViewBridge {
  private batch: BridgeMessage[] = [];
  private batchTimeout?: NodeJS.Timeout;
  private batchSize = 10;
  private batchDelay = 100; // ms

  sendEvent(action: string, payload?: unknown): void {
    if (this.shouldBatch(action)) {
      this.addToBatch({
        id: this.generateId(),
        type: MessageType.EVENT,
        action,
        payload,
        timestamp: Date.now(),
        version: '1.0'
      });
    } else {
      super.sendEvent(action, payload);
    }
  }

  private shouldBatch(action: string): boolean {
    // Batch analytics and non-critical events
    return action.startsWith('analytics.') ||
           action.startsWith('metrics.');
  }

  private addToBatch(message: BridgeMessage): void {
    this.batch.push(message);

    if (this.batch.length >= this.batchSize) {
      this.flushBatch();
    } else if (!this.batchTimeout) {
      this.batchTimeout = setTimeout(() => {
        this.flushBatch();
      }, this.batchDelay);
    }
  }

  private flushBatch(): void {
    if (this.batch.length === 0) return;

    const batchMessage: BridgeMessage = {
      id: this.generateId(),
      type: MessageType.EVENT,
      action: 'batch',
      payload: this.batch,
      timestamp: Date.now(),
      version: '1.0'
    };

    super.sendMessage(batchMessage);

    this.batch = [];
    if (this.batchTimeout) {
      clearTimeout(this.batchTimeout);
      this.batchTimeout = undefined;
    }
  }
}

Debugging and Monitoring

Production debugging is initially difficult. The following tooling makes it manageable:

Message Logging and Replay

class BridgeDebugger {
  private messageLog: Array<{
    timestamp: number;
    direction: 'sent' | 'received';
    message: BridgeMessage;
    duration?: number;
  }> = [];

  private maxLogSize = 1000;

  logSent(message: BridgeMessage): void {
    this.addToLog('sent', message);
  }

  logReceived(message: BridgeMessage, duration?: number): void {
    this.addToLog('received', message, duration);
  }

  private addToLog(
    direction: 'sent' | 'received',
    message: BridgeMessage,
    duration?: number
  ): void {
    this.messageLog.push({
      timestamp: Date.now(),
      direction,
      message,
      duration
    });

    // Keep log size manageable
    if (this.messageLog.length > this.maxLogSize) {
      this.messageLog.shift();
    }
  }

  // Export logs for debugging
  exportLogs(): string {
    return JSON.stringify(this.messageLog, null, 2);
  }

  // Get performance metrics
  getMetrics(): {
    totalMessages: number;
    averageResponseTime: number;
    slowestActions: Array<{ action: string; duration: number }>;
    errorRate: number;
  } {
    const requests = this.messageLog.filter(
      log => log.message.type === MessageType.REQUEST
    );

    const responses = this.messageLog.filter(
      log => log.message.type === MessageType.RESPONSE && log.duration
    );

    const errors = this.messageLog.filter(
      log => log.message.type === MessageType.ERROR
    );

    const responseTimes = responses
      .map(r => r.duration!)
      .filter(d => d > 0);

    const avgResponseTime = responseTimes.length > 0
      ? responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length
      : 0;

    const slowest = responses
      .filter(r => r.duration)
      .sort((a, b) => b.duration! - a.duration!)
      .slice(0, 10)
      .map(r => ({
        action: r.message.action,
        duration: r.duration!
      }));

    return {
      totalMessages: this.messageLog.length,
      averageResponseTime: Math.round(avgResponseTime),
      slowestActions: slowest,
      errorRate: errors.length / requests.length
    };
  }
}

Remote Debugging Setup

A remote debugging hook keeps the message stream visible during development:

// Development only - remote debugging
if (__DEV__) {
  const enableRemoteDebugging = () => {
    const ws = new WebSocket('ws://localhost:8080/bridge-debug');

    ws.onopen = () => {
      console.log('[Bridge Debug] Connected to debugger');
    };

    // Forward all bridge messages to debugger
    bridge.on('*', (message) => {
      ws.send(JSON.stringify({
        type: 'BRIDGE_MESSAGE',
        timestamp: Date.now(),
        message
      }));
    });
  };
}

Real-World Challenges and Solutions

The Race Condition Bug

The payment flow race condition described earlier unfolds as follows:

  1. WebView requests auth token
  2. Native app starts token refresh
  3. WebView times out waiting for response
  4. Native app completes refresh and sends response
  5. WebView has already moved on, response is ignored

The fix required implementing proper request cancellation:

class CancellableRequest {
  private cancelled = false;
  private cleanupFns: Array<() => void> = [];

  constructor(
    private promise: Promise<any>,
    private onCancel?: () => void
  ) {}

  then(onFulfilled: any, onRejected: any): Promise<any> {
    return this.promise.then(
      (value) => {
        if (this.cancelled) {
          throw new Error('Request cancelled');
        }
        return onFulfilled(value);
      },
      onRejected
    );
  }

  cancel(): void {
    this.cancelled = true;
    this.onCancel?.();
    this.cleanupFns.forEach(fn => fn());
  }

  addCleanup(fn: () => void): void {
    this.cleanupFns.push(fn);
  }
}

// Usage in bridge
request<TRequest>(
  action: string,
  payload: TRequest,
  timeoutMs = 10000
): CancellableRequest {
  const id = this.generateId();
  let timeoutId: NodeJS.Timeout;

  const message: BridgeMessage<TRequest> = {
    id,
    type: MessageType.REQUEST,
    action,
    payload,
    timestamp: Date.now(),
    version: '1.0'
  };

  const promise = new Promise((resolve, reject) => {
    timeoutId = setTimeout(() => {
      this.pendingRequests.delete(id);
      reject(new Error(`Request ${action} timed out`));
    }, timeoutMs);

    this.pendingRequests.set(id, { resolve, reject, timeout: timeoutId });
    this.sendMessage(message);
  });

  const request = new CancellableRequest(promise, () => {
    clearTimeout(timeoutId);
    this.pendingRequests.delete(id);

    // Notify the other side about cancellation
    this.sendEvent('request.cancelled', { requestId: id });
  });

  request.addCleanup(() => clearTimeout(timeoutId));

  return request;
}

Platform-Specific Quirks

iOS and Android WebViews behave differently in subtle ways:

// Platform-specific message handling
class PlatformBridge extends NativeWebViewBridge {
  protected sendMessage(message: BridgeMessage): void {
    if (Platform.OS === 'ios') {
      // iOS requires specific timing for postMessage
      requestAnimationFrame(() => {
        super.sendMessage(message);
      });
    } else {
      // Android can send immediately
      super.sendMessage(message);
    }
  }

  handleMessage(event: WebViewMessageEvent): void {
    // Android sends data as string, iOS as object sometimes
    const data = typeof event.nativeEvent.data === 'string'
      ? event.nativeEvent.data
      : JSON.stringify(event.nativeEvent.data);

    try {
      JSON.parse(data); // validate before handing off
      super.handleMessage({
        ...event,
        nativeEvent: { ...event.nativeEvent, data }
      } as WebViewMessageEvent);
    } catch (error) {
      console.error('[Bridge] Platform parsing error:', error);
    }
  }
}

When the Typed Bridge Is the Right Default

A typed request-response bridge earns its cost when the WebView calls back into native capabilities and the two sides ship on different release cycles. The id-plus-timeout envelope is what stops a slow token refresh from silently dropping a payment confirmation. Two cases argue against it. If the WebView is a leaf view that only renders content, a one-way injectedJavaScript payload is simpler and cheaper to maintain. If every module builds and deploys from a single pipeline, Module Federation gives direct function calls with no wire protocol to version.

Next in the Series

Part 3 covers:

  • Multi-channel rendering (same micro frontend in app, web, and desktop)
  • Production performance optimization techniques
  • Handling offline mode and sync
  • Security considerations and sandboxing

References

Mobile Micro Frontends with React Native

A comprehensive 3-part series on building mobile micro frontends using React Native, Expo, and WebViews. Covers architecture, communication patterns, and production optimization.

Progress 2/3 posts completed

All Posts in This Series

Related posts