Setting Up MSW v2 in Expo Router — What the Docs Don't Tell You

I was building a React Native app with Expo Router during a holiday break. The backend API wasn't ready yet, so I needed mock data to build the UI.

MSW (Mock Service Worker) is the most popular option for this. Unlike json-server, MSW intercepts requests at the network layer. This means you can write real API calls in your code and keep them unchanged — you just define the same API paths in MSW's handlers.

I followed the official MSW docs for React Native integration, but ran into several issues that the docs don't mention. This post walks through the official setup steps, the problems I hit along the way, and how I eventually got it working.

What the official docs tell you to do

The MSW team is upfront about this — their React Native docs include this note at the top:

This integration is potentially incomplete. If you are a React Native developer, please follow these steps and share any discrepancies/missing pieces with us on GitHub. Let's improve the React Native integration guidelines together.

Here are the five steps from the official docs.

1. Install dependencies

npm install msw --save-dev
npm install react-native-url-polyfill fast-text-encoding

Sources:

2. Create the polyfills

msw.polyfills.ts
import 'fast-text-encoding'
import 'react-native-url-polyfill/auto'

Source: React Native integration

3. Define your handlers

mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
    http.get('https://api.example.com/user', () => {
        return HttpResponse.json({
            id: 'abc-123',
            firstName: 'John',
            lastName: 'Maverick',
        })
    }),
]

Sources:

4. Create the server with msw/native

mocks/server.ts
import { setupServer } from 'msw/native'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

Use msw/native, not msw/node. The /node export patches Node.js's http and https modules, which don't exist in React Native and will crash your app. The /native export only intercepts fetch and XMLHttpRequest — the actual network APIs available in React Native.

Sources:

5. Enable mocking in the entry point of React Native application

index.ts
import { AppRegistry } from 'react-native'
import App from './src/App'
import { name as appName } from './app.json'

async function enableMocking() {
    if (!__DEV__) {
        return
    }

    await import('./msw.polyfills')
    const { server } = await import('./src/mocks/server')
    server.listen()
}

enableMocking().then(() => {
    AppRegistry.registerComponent(appName, () => App)
})

Source: React Native integration

If you're using Expo Router, Step 5 won't work as written. Here's what happened.

What actually broke (and how I fixed it)

1. The entry point doesn't work with Expo Router

The official docs show you initializing MSW in index.ts before AppRegistry.registerComponent. But with Expo Router, there is no index.ts — the entry point is expo-router/entry, which handles app registration internally.

package.json
{ "main": "expo-router/entry" }
From Expo docs: For the property main, use the expo-router/entry as its value in the package.json. The initial client file is src/app/_layout.tsx (or app/_layout.tsx if not using the src directory).

There are two approaches that work. Pick whichever fits your project better.

Approach A: Custom entry point with top-level await

You can create a custom index.ts that loads MSW first, then imports expo-router/entry:

index.ts
async function enableMocking() {
    if (!__DEV__) {
        return;
    }

    await import("./msw.polyfills");
    const { server } = await import("./mocks/server");
    server.listen();
}

await enableMocking();
await import("expo-router/entry");
package.json
{ "main": "index.ts" }

Since you're no longer using expo-router/entry directly, you'll also need a type declaration:

types/expo-router-entry.d.ts
declare module 'expo-router/entry'

A warning about .then(). You might be tempted to write this instead:

// ❌ This will crash
enableMocking().then(() => {
    import('expo-router/entry')
})

This crashes with "Module has not been registered as callable. Registered callable JavaScript modules (n = 0)".

The reason: .then() is non-blocking.

The JS engine considers the module "done loading" immediately, the native side calls AppRegistry.runApplication(), but the .then() callback hasn't fired yet — so nothing is registered.

Top-level await is different. It pauses the entire module's evaluation until the Promise resolves. The module isn't marked as "loaded" until all awaits complete, so by the time the native side runs, expo-router/entry has already called registerComponent.

Approach B: Initialize in the root layout

If you prefer not to touch the entry point, you can initialize MSW inside app/_layout.tsx instead. This is Expo Router's first rendered file and the official place for initialization logic.

app/_layout.tsx
import { Stack } from "expo-router";
import { useEffect, useState } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";

export default function RootLayout() {
    const [isMockingReady, setIsMockingReady] = useState(!__DEV__);

    useEffect(() => {
        async function enableMocking() {
            if (!__DEV__) return;

            try {
                await import("../msw.polyfills");
                const { server } = await import("../mocks/server");
                server.listen({ onUnhandledRequest: "bypass" });
                console.log("[MSW] Mocking enabled");
            } catch (error) {
                console.error("[MSW] Setup failed:", error);
            }

            setIsMockingReady(true);
        }

        enableMocking();
    }, []);

    if (!isMockingReady) {
        return null;
    }

    return (
        <GestureHandlerRootView style={{ flex: 1 }}>
            <Stack>
                <Stack.Screen
                    name="(tabs)"
                    options={{ headerShown: false }}
                />
            </Stack>
        </GestureHandlerRootView>
    );
}

useState(!__DEV__) is the key trick here. __DEV__ is a global boolean that Metro injects at build time — true in development, false in production. So !__DEV__ means: in production, isMockingReady starts as true and the app renders immediately with zero delay. In development, it starts as false, shows nothing while MSW initializes, then renders the app once mocking is ready.

Which approach should you pick?

Approach A (custom entry point) is cleaner — MSW starts before any component renders, so there's no loading state to manage. But it relies on top-level await support in Metro, which may not work in older Expo SDK versions.

Approach B (root layout) is more conservative. It doesn't depend on any special module loading behavior, just standard React lifecycle. The trade-off is a brief blank screen during MSW initialization in development.

I use Approach A in my project, but either works.


2. The polyfills are incomplete

After fixing the entry point, I got this error:

Uncaught Error: Property 'MessageEvent' doesn't exist

Followed by:

TypeError: Cannot read property 'listen' of undefined

The second error is a consequence of the first. When MessageEvent is missing, setupServer() silently fails and returns undefined. Then calling server.listen() crashes because server is undefined.

The official polyfills file only includes URL and TextEncoder. But MSW v2 internally references four additional browser APIs — MessageEvent, Event, EventTarget, and BroadcastChannel — none of which exist in Hermes (React Native's JS engine). MSW uses these for WebSocket support, but it references them even when you're only mocking HTTP.

This has been reported as an open issue on the MSW docs repo. The fix comes from a community blog post — add minimal stubs for the missing globals:

msw.polyfills.ts
import 'fast-text-encoding'
import 'react-native-url-polyfill/auto'

function defineMockGlobal(name: string) {
    if (typeof (global as any)[name] === 'undefined') {
        ;(global as any)[name] = class {
            type: string
            constructor(type: string, eventInitDict?: Record<string, any>) {
                this.type = type
                Object.assign(this, eventInitDict)
            }
        }
    }
}

;['MessageEvent', 'Event', 'EventTarget', 'BroadcastChannel'].forEach(
    defineMockGlobal,
)
These aren't full implementations — they're minimal stubs with just enough structure to prevent MSW from crashing during initialization. MSW references these classes internally for WebSocket support, even when you're only mocking HTTP.

Things I learned along the way

How React Native startup actually works

React Native apps have two separate sides running at the same time: the native side (Objective-C on iOS, Kotlin on Android) that controls the actual screen, and the JS side (your React code running in Hermes). They communicate through a bridge — the native side sends events to JS, and JS sends UI updates back.

The important part: these two sides don't share a call stack. The native side can't await anything on the JS side. It fires a message and moves on.

So here's what happens at startup:

  1. The native side tells Metro to load the JS bundle
  2. The JS bundle executes top-to-bottom — this is where registerComponent needs to run
  3. Once the bundle is marked as "loaded", the native side calls AppRegistry.runApplication()

If registerComponent hasn't been called by step 3, the native side finds nothing registered and crashes with "Registered callable JavaScript modules (n = 0)".

The key insight is: the native side waits for the bundle to finish loading, but not for JS Promises to resolve. This is the difference between .then() and top-level await.

Why .then() crashes:

enableMocking().then(() => { import('expo-router/entry') })

.then() schedules a callback but doesn't block execution. As far as the JS engine is concerned, this module is done — it executed every line. So the bundle is marked as "loaded" and the native side fires runApplication(). But the .then() callback hasn't run yet, so registerComponent was never called.

Why top-level await works:

await enableMocking()
await import('expo-router/entry')

Top-level await tells the JS engine: "this module is not finished evaluating yet." The bundle stays in a "still loading" state until all awaits resolve. The native side doesn't fire runApplication() until the bundle is fully loaded — and by that point, expo-router/entry has already run and registered the component.

Source: React Native AppRegistry docs

Static imports get hoisted — you can't control their order

When I first tried to fix the entry point, I assumed I could just reorder my imports:

// I expected this to run top-to-bottom
import './msw.polyfills'
import './mocks/server'
import 'expo-router/entry'

This doesn't work because static import statements are hoisted — the JS engine moves them all to the top and runs them before any other code, regardless of where you write them. You can't guarantee order between static imports.

Dynamic import() is different. It returns a Promise and runs exactly when you call it:

// This actually runs in order
await import('./msw.polyfills')
await import('./mocks/server')
await import('expo-router/entry')

Each await waits for the previous import to complete before starting the next one. No hoisting, no reordering.

What __DEV__ actually is

It's not magic — it's literally a variable that Metro (the bundler) writes into the first line of your JS bundle at build time. In a development bundle, Metro writes var __DEV__ = true. In a production bundle, it writes var __DEV__ = false.

This means if (!__DEV__) return isn't a runtime check against some environment variable. It's a check against a hardcoded boolean. And because it's hardcoded, production builds can potentially tree-shake the entire MSW code path away.


Final file structure

├── app/
│   └── _layout.tsx
├── mocks/
│   ├── handlers.ts
│   └── server.ts          # uses msw/native
├── msw.polyfills.ts        # 2 packages + 4 global stubs
├── index.ts                # only if using Approach A
├── types/
│   └── expo-router-entry.d.ts  # only if using Approach A
└── package.json

Sources