Skip to content

WebView Communication Patterns: Building a Type-Safe Bridge Between Native and Web

Deep dive into WebView-native communication patterns, message passing systems, and service integration. Real production code, performance benchmarks, and debugging stories from building robust message bridges. Includes Rspack, Re.Pack, and alternative bridge approaches.

Three months after launching our mobile micro frontend architecture, we hit a wall. Our WebView-native communication was becoming a debugging challenge. Messages were getting lost, types didn't match between platforms, and we had no way to track what was happening inside WebViews in production.

During a product launch, the payment flow - split between native authentication and a WebView checkout - started failing for 15% of Android users. The root cause? A race condition in the message passing system that only appeared on devices with slower JavaScript engines.

Here's how we rebuilt our communication layer from scratch and what we learned from our production WebView communication system.

Mobile Micro Frontend Series

This is Part 2 of our mobile micro frontends series:

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

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

Alternative Communication Approaches

Before diving into our WebView communication solution, let me share the alternative approaches we evaluated and why we chose our current system.

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.

What we experimented with:

typescript
// 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.tsexport interface NativeBridge {  getAuthToken(): Promise<string>;  openCamera(): Promise<string>;  navigate(screen: string, params?: any): void;}
// In micro frontendimport { 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>;};

Why we didn't choose it:

  • Complexity: Required all teams to adopt Module Federation simultaneously
  • Type safety: Shared modules needed to be in a separate package
  • Versioning: Module version conflicts were hard to debug
  • Performance: Initial bundle size increased significantly
  • Debugging: Stack traces spanned 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
  • Note: Re.Pack has evolved significantly since 2023, with improved React Native 0.73+ support and better Metro integration

Option 2: Rspack Module Federation

We also experimented with Rspack's Module Federation for communication:

typescript
// rspack.config.mjsexport 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.tsexport class RspackBridge {  private static instance: RspackBridge;
  static getInstance(): RspackBridge {    if (!RspackBridge.instance) {      RspackBridge.instance = new RspackBridge();    }    return RspackBridge.instance;  }
  async request<T>(action: string, payload?: any): Promise<T> {    // Use Rspack's built-in communication    return new Promise((resolve, reject) => {      const timeout = setTimeout(() => {        reject(new Error(`Request ${action} timed out`));      }, 10000);
      // Rspack-specific communication      window.__RSPACK_MODULE_FEDERATION__.request(action, payload)        .then((result: T) => {          clearTimeout(timeout);          resolve(result);        })        .catch((error: any) => {          clearTimeout(timeout);          reject(error);        });    });  }}

Why we didn't choose it:

  • React Native compatibility: Limited React Native support (as of 2025)
  • Ecosystem maturity: Still evolving debugging tools and examples
  • Team adoption: Would require significant retraining
  • 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
  • Note: Rspack has matured significantly by 2025, with better stability and tooling, but Module Federation for mobile remains limited

Option 3: Web Workers + SharedArrayBuffer

For high-performance communication, we evaluated using Web Workers with SharedArrayBuffer:

typescript
// High-performance communication using SharedArrayBufferclass 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();    });  }}

Why we didn't choose it:

  • Browser support: SharedArrayBuffer requires specific 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, we considered WebSockets:

typescript
// WebSocket-based bridgeclass 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));      }    }  }}

Why we didn't choose it:

  • Network dependency: Requires network connection
  • Latency: Additional network hop
  • Complexity: Need to manage WebSocket lifecycle
  • 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

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

Building a Robust Message Protocol

After several iterations, we developed a protocol that solved these issues:

typescript
// Shared types between native and webinterface 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

Here's our production bridge implementation on the React Native side:

typescript
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 one-way event  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);  }
  private sendMessage(message: BridgeMessage): void {    if (!this.isReady) {      // Queue messages until WebView is ready      this.messageQueue.push(message);      return;    }
    const serialized = JSON.stringify(message);
    // Critical: use postMessage, not injectJavaScript for reliability    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 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:

typescript
// WebViewBridge.ts - injected into WebViewclass 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 globallydeclare global {  interface Window {    bridge: WebViewBridge;    ReactNativeWebView?: {      postMessage: (message: string) => void;    };  }}
window.bridge = new WebViewBridge();

Type-Safe Communication

One of our biggest wins was achieving type safety across the bridge. Here's how we did it:

typescript
// 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 wrapperexport 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:

typescript
// In WebViewconst bridge = new TypedBridge(window.bridge);
// TypeScript knows the exact request/response typesconst { token } = await bridge.request('auth.getToken', undefined);const photo = await bridge.request('camera.takePhoto', { quality: 0.8 });
// Type error if you pass wrong parameters// bridge.request('auth.getToken', { wrong: 'param' }); // Bad: TypeScript error

Service Integration Patterns

Here's how we integrated various services through the bridge:

Authentication Service

typescript
// Native sideclass 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.emit('auth.sessionExpired');
        this.bridge.sendResponse(message.id, false, null, {          code: 'REFRESH_FAILED',          message: 'Session expired'        });      }    });  }}
// Web side - Auto-refreshing fetch wrapperclass 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

Here's how we exposed native features to WebViews:

typescript
// Camera integrationclass 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 usageasync 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

After deploying to production, we discovered several performance bottlenecks:

Large Payload Handling

Sending large payloads (images, documents) through postMessage was slow and could freeze the UI. Our solution was chunking:

typescript
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

For high-frequency events like analytics, we implemented batching:

typescript
class BatchedBridge extends NativeWebViewBridge {  private batch: BridgeMessage[] = [];  private batchTimeout?: NodeJS.Timeout;  private batchSize = 10;  private batchDelay = 100; // ms
  emit(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.emit(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 was initially difficult. Here's how we made it manageable:

Message Logging and Replay

typescript
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

For production debugging, we implemented a remote debugging capability:

typescript
// Development only - remote debuggingif (__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

Remember the payment flow bug I mentioned? Here's what was happening:

  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:

typescript
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 bridgerequest<TRequest, TResponse>(  action: string,  payload: TRequest,  timeoutMs = 10000): CancellableRequest {  const id = this.generateId();  let timeoutId: NodeJS.Timeout;
  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 native side about cancellation    this.emit('request.cancelled', { requestId: id });  });
  request.addCleanup(() => clearTimeout(timeoutId));
  return request;}

Platform-Specific Quirks

iOS and Android WebViews behave differently in subtle ways:

typescript
// Platform-specific message handlingclass PlatformBridge extends NativeWebViewBridge {  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 {      const message = JSON.parse(data);      super.handleMessage({        nativeEvent: { ...event.nativeEvent, data }      });    } catch (error) {      console.error('[Bridge] Platform parsing error:', error);    }  }}

Performance Results

These patterns led to significant improvements in our metrics:

Message Performance

Based on testing with our production workload over 30 days:

  • Average response time: 45ms (down from 180ms with previous implementation)
  • 99th percentile: 200ms (down from 2s with basic postMessage)
  • Failed messages: 0.01% (down from 2.3% without retry logic)

Memory Usage

Measured using React Native performance profiler:

  • Bridge overhead: ~5MB per WebView
  • Message queue peak: 15MB (with batching enabled)
  • No memory leaks detected after 24h stress testing

Battery Impact

Measured using Xcode Instruments and Android Battery Historian:

  • 5% reduction in battery drain compared to frequent individual messages
  • Improvement mainly from batching and reducing message frequency

Key Takeaways

  1. Design for Failure: Every message can fail. Build retry and timeout handling from day one.

  2. Type Safety is Critical: The investment in TypeScript types across the bridge paid off 10x in reduced debugging time.

  3. Performance Requires Batching: Individual messages are expensive. Batch when possible.

  4. Platform Differences Matter: Test thoroughly on both iOS and Android, especially older devices.

  5. Debugging Tools are Essential: You can't fix what you can't see. Build comprehensive logging early.

  6. Alternative Approaches Have Trade-offs: Each communication method has its strengths. Choose based on your specific needs.

What's Next?

In Part 3, we'll explore:

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

The communication layer is the heart of mobile micro frontends. Get it right, and everything else becomes manageable. Get it wrong, and you'll be debugging race conditions during critical incidents like we were.

Next time, we'll look at how this architecture scales across multiple platforms and the surprising optimizations we found in production.

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.

Progress2/3 posts completed

Related Posts