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:

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:

TypeScript
// 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:

TypeScript
// 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:

TypeScript
// 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:

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

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

TypeScript
// 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:

TypeScript
// 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:

TypeScript
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#

  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.

Loading...

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!

Related Posts