Mobile Micro Frontends with React Native Expo: WebView Architecture and Real-World Implementation
Deep dive into implementing micro frontend architecture in mobile apps using React Native Expo and WebViews. Real production experiences, performance data, and battle-tested patterns. Includes Rspack, Re.Pack, and alternative bundler approaches.
Last year, our team faced a challenge that's becoming increasingly common: we needed to build a mobile app that could integrate multiple web-based services from different teams, each with their own deployment cycles and tech stacks. The catch? We had 8 weeks to deliver, and the web teams couldn't stop their feature development to help us.
That's when we decided to bring micro frontend architecture to our React Native app using WebViews. What followed was 6 months of debugging, performance optimization, and some creative solutions I never thought I'd implement. Here's what we learned.
Mobile Micro Frontend Series#
This is Part 1 of a comprehensive 3-part series on mobile micro frontends:
- Part 1 (You are here): Architecture fundamentals and WebView integration patterns
- Part 2: WebView communication patterns and service integration
- Part 3: Multi-channel architecture and production optimization
New to mobile micro frontends? Start here to understand the architecture and implementation basics.
Ready to implement? Jump to Part 2 for communication patterns.
Running in production? Check Part 3 for optimization strategies.
Why Mobile Micro Frontends?#
Traditional mobile app development has a fundamental problem: native code deployment is slow. App store reviews, user update adoption, and coordinating releases across multiple teams create bottlenecks that web developers solved years ago.
Our specific constraints were:
- 5 different web teams with existing React/Vue/Angular applications
- Weekly web deployments vs monthly mobile releases
- A/B testing requirements that couldn't wait for app updates
- Compliance features that needed immediate deployment capability
The solution? Embed web-based micro frontends in our React Native app using WebViews.
Architecture Overview#
Here's the high-level architecture we implemented:
Loading diagram...
The native app acts as a shell that:
- Handles authentication and session management
- Provides native functionality (camera, biometrics, etc.)
- Manages navigation between micro frontends
- Implements a communication bridge between WebViews and native code
Alternative Approaches: Beyond Traditional WebViews#
Before diving into our WebView implementation, let me share the alternative approaches we evaluated and why we chose WebViews over them.
Option 1: Re.Pack with Module Federation#
Re.Pack is Callstack's solution for bringing Module Federation to React Native. It's essentially webpack's Module Federation running in React Native.
What we tried:
// Re.Pack configuration
const { ModuleFederationPlugin } = require('@module-federation/nextjs-mf');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
booking: 'booking@http://localhost:3001/remoteEntry.js',
shopping: 'shopping@http://localhost:3002/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true }
}
})
]
};
Why we didn't choose it:
- Complexity: Required significant changes to our existing webpack configurations
- Team coordination: All teams needed to adopt Re.Pack simultaneously
- Debugging: Module Federation debugging in React Native was still immature
- Performance: Initial bundle size increased by 40% due to federation overhead
When to use Re.Pack:
- You're starting fresh with a new project
- All teams can coordinate on the same bundler
- You need true runtime module sharing
- You're building a "super app" with multiple independent teams
Option 2: Rspack with React Native#
Rspack is a Rust-based bundler that's webpack-compatible but much faster. We experimented with using Rspack for our micro frontend builds.
What we tried:
// rspack.config.mjs
export default {
entry: './src/index.tsx',
module: {
rules: [
{
test: /\.tsx$/,
use: {
loader: 'builtin:swc-loader',
options: {
jsc: {
parser: {
syntax: 'typescript',
tsx: true
},
transform: {
react: {
runtime: 'automatic'
}
}
}
}
}
}
]
},
plugins: [
new ModuleFederationPlugin({
name: 'micro-frontend',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App.tsx'
}
})
]
};
Why we didn't choose it:
- React Native compatibility: Rspack doesn't have native React Native support yet
- Ecosystem maturity: Fewer plugins and loaders compared to webpack
- Team adoption: Would require retraining 5 teams on a new bundler
- Production stability: We couldn't risk our timeline on a newer tool
When to use Rspack:
- You're building web-only micro frontends
- Build performance is critical (Rspack is 10x faster than webpack)
- You can afford to be an early adopter
- Your teams are comfortable with Rust-based tooling
Option 3: Vite + React Native#
We also evaluated using Vite for our micro frontend builds, leveraging its fast HMR and modern build system.
What we tried:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
utils: ['lodash', 'date-fns']
}
}
}
},
server: {
cors: true
}
});
Why we didn't choose it:
- Module Federation: Vite's Module Federation support was still experimental
- Production builds: Our production builds were slower than webpack
- Plugin ecosystem: Fewer plugins for our specific needs
- Team familiarity: Teams were more comfortable with webpack
When to use Vite:
- You're building modern web applications
- Development speed is more important than production optimization
- You don't need complex Module Federation
- Your teams prefer modern tooling
Option 4: Hybrid Approach (What We Actually Did)#
After evaluating all options, we chose a hybrid approach that gave us the best of all worlds:
Loading diagram...
Why this worked:
- Team autonomy: Each team could use their preferred bundler
- Gradual migration: Teams could migrate to better tools over time
- Risk mitigation: If one approach failed, others continued working
- Performance: Each team could optimize their own builds
Implementation: The Real Story#
Setting Up the WebView Architecture#
We started with Expo's WebView, but quickly ran into our first challenge. The initial implementation looked deceptively simple:
import React from 'react';
import { WebView } from 'react-native-webview';
import { SafeAreaView } from 'react-native-safe-area-context';
export function MicroFrontendContainer({ url }: { url: string }) {
return (
<SafeAreaView style={{ flex: 1 }}>
<WebView
source={{ uri: url }}
style={{ flex: 1 }}
/>
</SafeAreaView>
);
}
This worked for exactly 5 minutes in development before we hit our first production issue: the WebView would randomly show a white screen on Android devices with less than 4GB RAM.
The Memory Problem#
After adding crash reporting, we discovered WebViews were consuming 150-200MB each. With 3 micro frontends loaded, we were hitting memory limits on lower-end devices. Here's how we solved it:
import React, { useRef, useCallback, useState } from 'react';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { AppState, AppStateStatus } from 'react-native';
interface MicroFrontendConfig {
url: string;
preload?: boolean;
cachePolicy?: 'default' | 'reload' | 'cache-else-load';
}
class WebViewManager {
private static instance: WebViewManager;
private activeWebViews = new Map<string, {
ref: React.RefObject<WebView>;
lastUsed: number;
memoryUsage: number;
}>();
private maxWebViews = 3;
private maxMemoryPerWebView = 100 * 1024 * 1024; // 100MB
static getInstance(): WebViewManager {
if (!WebViewManager.instance) {
WebViewManager.instance = new WebViewManager();
}
return WebViewManager.instance;
}
registerWebView(id: string, ref: React.RefObject<WebView>): void {
this.activeWebViews.set(id, {
ref,
lastUsed: Date.now(),
memoryUsage: 0
});
// Cleanup if we exceed limits
this.cleanupIfNeeded();
}
private cleanupIfNeeded(): void {
if (this.activeWebViews.size <= this.maxWebViews) return;
// Find least recently used WebView
const entries = Array.from(this.activeWebViews.entries());
const lru = entries.reduce((min, current) =>
current[1].lastUsed < min[1].lastUsed ? current : min
);
// Clear the WebView
lru[1].ref.current?.clearCache();
this.activeWebViews.delete(lru[0]);
console.log(`[WebViewManager] Cleared WebView ${lru[0]} due to memory limits`);
}
updateUsage(id: string): void {
const webView = this.activeWebViews.get(id);
if (webView) {
webView.lastUsed = Date.now();
}
}
}
export function OptimizedMicroFrontendContainer({
config
}: {
config: MicroFrontendConfig
}) {
const webViewRef = useRef<WebView>(null);
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const webViewManager = WebViewManager.getInstance();
const handleLoadStart = useCallback(() => {
setIsLoading(true);
setHasError(false);
}, []);
const handleLoadEnd = useCallback(() => {
setIsLoading(false);
webViewManager.updateUsage(config.url);
}, [config.url]);
const handleError = useCallback((error: any) => {
console.error('[WebView] Load error:', error);
setHasError(true);
setIsLoading(false);
}, []);
const handleMessage = useCallback((event: WebViewMessageEvent) => {
// Handle bridge messages (covered in Part 2)
console.log('[WebView] Message received:', event.nativeEvent.data);
}, []);
// Register with manager
React.useEffect(() => {
webViewManager.registerWebView(config.url, webViewRef);
}, [config.url]);
// Handle app state changes
React.useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState === 'background') {
// Clear cache when app goes to background
webViewRef.current?.clearCache();
}
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => subscription?.remove();
}, []);
if (hasError) {
return (
<SafeAreaView style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Failed to load micro frontend</Text>
<Button title="Retry" onPress={() => setHasError(false)} />
</SafeAreaView>
);
}
return (
<SafeAreaView style={{ flex: 1 }}>
{isLoading && (
<View style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'white',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000
}}>
<ActivityIndicator size="large" />
</View>
)}
<WebView
ref={webViewRef}
source={{
uri: config.url,
headers: {
'X-Platform': 'react-native',
'X-App-Version': '1.0.0'
}
}}
style={{ flex: 1 }}
onLoadStart={handleLoadStart}
onLoadEnd={handleLoadEnd}
onError={handleError}
onMessage={handleMessage}
cacheEnabled={config.cachePolicy !== 'reload'}
cacheMode={config.cachePolicy === 'cache-else-load' ? 'LOAD_CACHE_ELSE_NETWORK' : 'LOAD_DEFAULT'}
// Critical for performance
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={true}
scalesPageToFit={true}
// Security
allowsInlineMediaPlayback={false}
mediaPlaybackRequiresUserAction={true}
// Performance
removeClippedSubviews={true}
overScrollMode="never"
/>
</SafeAreaView>
);
}
The Bundle Size Problem#
Our initial micro frontend bundles were massive - 2-3MB each. This caused slow loading times and poor user experience. Here's how we optimized:
// webpack.config.js - Optimized for WebView delivery
const { ModuleFederationPlugin } = require('webpack').container;
const TerserPlugin = require('terser-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 10
},
common: {
name: 'common',
minChunks: 2,
chunks: 'all',
priority: 5
}
}
},
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
drop_console: true, // Remove console.logs
drop_debugger: true
},
mangle: {
safari10: true // Fix Safari 10 issues
}
}
})
]
},
plugins: [
new CompressionPlugin({
algorithm: 'gzip',
test: /\.(js|css|html|svg)$/,
threshold: 10240,
minRatio: 0.8
}),
new ModuleFederationPlugin({
name: 'micro-frontend',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App.tsx'
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' }
}
})
]
};
Alternative: Rspack Configuration#
For teams that wanted to try Rspack, here's the equivalent configuration:
// rspack.config.mjs - Rspack version
export default {
entry: './src/index.tsx',
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 10
}
}
}
},
module: {
rules: [
{
test: /\.tsx$/,
use: {
loader: 'builtin:swc-loader',
options: {
jsc: {
parser: {
syntax: 'typescript',
tsx: true
},
transform: {
react: {
runtime: 'automatic'
}
},
minify: {
compress: {
drop_console: true,
drop_debugger: true
}
}
}
}
}
}
]
},
plugins: [
new ModuleFederationPlugin({
name: 'micro-frontend',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App.tsx'
}
})
]
};
Performance comparison:
- Webpack build time: 45 seconds
- Rspack build time: 8 seconds (82% faster)
- Bundle size: Similar (within 5%)
- Memory usage: Rspack used 30% less memory during builds
The Navigation Problem#
WebView navigation doesn't work like native navigation. Users expect the back button to work, but WebViews have their own history. Here's our solution:
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { BackHandler } from 'react-native';
export function WebViewWithNavigation({
webViewRef,
canGoBack,
onGoBack
}: {
webViewRef: React.RefObject<WebView>;
canGoBack: boolean;
onGoBack: () => void;
}) {
const navigation = useNavigation();
useFocusEffect(
React.useCallback(() => {
const onBackPress = () => {
if (canGoBack) {
// Try to go back in WebView first
webViewRef.current?.goBack();
return true; // Prevent default back behavior
} else {
// Let native navigation handle it
onGoBack();
return false;
}
};
BackHandler.addEventListener('hardwareBackPress', onBackPress);
return () => BackHandler.removeEventListener('hardwareBackPress', onBackPress);
}, [canGoBack, onGoBack])
);
return null;
}
Performance Results#
After implementing these optimizations, our metrics improved dramatically:
Loading Performance#
- Initial load time: 2.3s → 0.8s (65% improvement)
- Bundle size: 2.8MB → 1.2MB (57% reduction)
- Memory usage: 200MB → 120MB per WebView (40% reduction)
User Experience#
- App crashes: 2.3% → 0.1% (96% reduction)
- White screen incidents: 15% → 1% (93% reduction)
- User satisfaction score: 3.2 → 4.6 (44% improvement)
Build Performance#
- Webpack builds: 45s → 35s (22% faster)
- Rspack builds: 8s (82% faster than webpack)
- Development HMR: 2s → 0.3s (85% faster)
Key Takeaways#
-
WebViews are viable but require optimization: They work well for micro frontends but need careful memory and performance management.
-
Hybrid approaches work best: Let teams use their preferred tools while maintaining a consistent interface.
-
Rspack shows promise: For new projects, consider Rspack for its speed and webpack compatibility.
-
Re.Pack is powerful but complex: Great for super apps but requires significant coordination.
-
Performance is critical: Users notice slow WebViews immediately. Optimize aggressively.
What's Next?#
In Part 2, we'll dive into:
- Building a robust communication bridge between WebViews and native code
- Type-safe message passing
- Handling authentication and native features
- Debugging and monitoring strategies
The foundation is crucial. Get the architecture right, and everything else becomes manageable. Get it wrong, and you'll be fighting performance issues forever.
Next time, we'll look at how to make WebViews and native code talk to each other reliably, and the debugging nightmares we solved along the way.
Comments (0)
Join the conversation
Sign in to share your thoughts and engage with the community
No comments yet
Be the first to share your thoughts on this post!
Comments (0)
Join the conversation
Sign in to share your thoughts and engage with the community
No comments yet
Be the first to share your thoughts on this post!