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:
- Part 1: Architecture fundamentals and WebView integration patterns
- Part 2 (You are here): WebView communication patterns and service integration
- Part 3: Multi-channel architecture and production optimization
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:
- WebView requests auth token
- Native app starts token refresh
- WebView times out waiting for response
- Native app completes refresh and sends response
- 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
- react-native-webview Guide - Communication - Official guide covering JavaScript-to-native and native-to-JavaScript message passing via postMessage and onMessage
- react-native-webview API Reference - Complete API reference for WebView props including injectedJavaScript, onMessage, and postMessage
- Window: postMessage() - MDN Web Docs - MDN reference for the postMessage API used for cross-origin communication between WebView and native code
- Re.Pack - Module Federation Communication - Re.Pack’s documentation on inter-module communication patterns in React Native micro frontends
- React Native Performance Overview - Official documentation on threading model and performance considerations relevant to bridge message handling
- Expo WebView Documentation - Expo’s reference for WebView configuration including communication setup and platform-specific behavior
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.
All Posts in This Series
Related posts
How to implement micro frontend architecture in mobile apps with React Native Expo and WebViews, covering performance, proven patterns, and bundlers.
Patterns for shipping micro frontends across mobile, web, and desktop: performance, offline support, and production insights, with Rspack and Re.Pack approaches.
Server-Driven UI is the mobile analog of server-side composition. The hard part is not JSON rendering but a versioned component contract that survives old app versions.
Step-by-step guide to adding Sentry to a React Native Expo app: SDK setup, Expo Router instrumentation, session replay, and source maps for EAS.
A practical comparison of headless CMS options (Strapi, Contentful, Kontent, Storyblok) with Cloudinary image management and framework integration.