2025-09-04
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:
- 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.
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:
// 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>;
};
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:
// 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
export 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:
// 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();
});
}
}
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:
// 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));
}
}
}
}
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:
// 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
Here’s our 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 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:
// 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
One of our biggest wins was achieving type safety across the bridge. Here’s how we did it:
// 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 });
// 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
// 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.emit('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
Here’s how we exposed native features to WebViews:
// 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
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:
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:
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
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:
// 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
Remember the payment flow bug I mentioned? Here’s what was happening:
- 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, 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:
// Platform-specific message handling
class 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
-
Design for Failure: Every message can fail. Build retry and timeout handling from day one.
-
Type Safety is Critical: The investment in TypeScript types across the bridge paid off 10x in reduced debugging time.
-
Performance Requires Batching: Individual messages are expensive. Batch when possible.
-
Platform Differences Matter: Test thoroughly on both iOS and Android, especially older devices.
-
Debugging Tools are Essential: You can’t fix what you can’t see. Build comprehensive logging early.
-
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.
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
Deep dive into implementing micro frontend architecture in mobile apps using React Native Expo and WebViews. Real production experiences, performance data, and proven patterns. Includes Rspack, Re.Pack, and alternative bundler approaches.
Advanced patterns for deploying micro frontends across mobile, web, and desktop. Performance optimization strategies, offline support, and production insights from scaling enterprise applications. Includes Rspack, Re.Pack, and alternative bundler approaches.
Step-by-step guide to integrating Sentry error monitoring into a React Native Expo app. Covers SDK initialization, Expo Router instrumentation, session replay, source map uploads for EAS Build and EAS Update, and common pitfalls to avoid.
A practical comparison of headless CMS solutions - Strapi, Contentful, Kontent, and Storyblok - including image management with Cloudinary and framework integration patterns for web and mobile applications.
Production-ready Module Federation configurations, cross-micro frontend communication, routing strategies, and practical implementation patterns with real debugging examples.