Skip to content

2025-09-04

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 proven patterns. Includes Rspack, Re.Pack, and alternative bundler approaches.

Mobile teams increasingly face the challenge of integrating multiple web-based services from different teams, each with their own deployment cycles and tech stacks. Tight delivery timelines compound this complexity when web teams can’t pause their development to assist with mobile integration.

Micro frontend architecture offers a solution for React Native apps using WebViews. This approach involves significant debugging, performance optimization, and creative problem-solving. Here’s what works in practice.

Mobile Micro Frontend Series

This is Part 1 of a comprehensive 3-part series on mobile micro frontends:

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.

Common constraints in this scenario include:

  • 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:

React Native App

Native Shell

WebView Container 1

WebView Container 2

WebView Container 3

Team A: React App

Team B: Vue App

Team C: Angular App

Native Bridge

Authentication Service

Native Features

Analytics

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 with webpack Module Federation
const { ModuleFederationPlugin } = require('@module-federation/webpack');

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-native': { 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:

React Native App

Native Shell

WebView Container

Micro Frontend Loader

Team A: Webpack Build

Team B: Vite Build

Team C: Rspack Build

Shared Bridge

Authentication

Native Features

Analytics

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

Crash reporting often reveals WebViews consuming 150-200MB each. Multiple micro frontends can hit memory limits on lower-end devices. Here’s a solution:

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

Initial micro frontend bundles often reach 2-3MB each, causing slow loading times and poor user experience. Here’s how to optimize:

// 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 uses 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

These optimizations typically lead to significant improvements:

Loading Performance

  • Initial load time reductions of 60-70%
  • Bundle size reductions of 50-60%
  • Memory usage reductions of 35-45% per WebView

User Experience

  • Substantial crash rate reductions (90%+)
  • White screen incident reductions (90%+)
  • Improved user satisfaction scores

Build Performance

  • Webpack build improvements of 20-25%
  • Rspack builds typically 80%+ faster than webpack
  • Development HMR improvements of 80%+

Key Takeaways

  1. WebViews are viable but require optimization: They work well for micro frontends but need careful memory and performance management.

  2. Hybrid approaches work best: Let teams use their preferred tools while maintaining a consistent interface.

  3. Rspack shows promise: For new projects, consider Rspack for its speed and webpack compatibility.

  4. Re.Pack is powerful but complex: Great for super apps but requires significant coordination.

  5. 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.

Mobile Micro Frontends with React Native

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

Progress 1 of 3 posts

All posts in this series

Related posts

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.

expomobilemodule-federation+5
Multi-Channel Micro Frontends: Production Optimization and Cross-Platform Rendering

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.

expoperformancere-pack+4
Sentry Integration with React Native Expo: A Practical Quick Guide

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.

react-nativeexpomonitoring+2
Async API Patterns for Web and Mobile: An Opinionated Default

One default shape for long-running work across a browser SPA and a mobile app, with the cases where it should be overridden.

api-designwebsocketsserver-sent-events+5
A UX Guide for Async Backends: Optimistic, Decoupled, or Neither

A pragmatic guide for designers working with async backends: three interaction patterns, when to use each, and four anti-patterns to push back against.

event-drivenstate-managementpatterns+2