Sharing MSW Handlers Between Browser and Node

You have handlers for the browser, a nearly identical set for the test runner, and they have quietly diverged — the component test passes and the same flow fails in the dev server. This page collapses them into one module that three consumers import, and explains the one thing that genuinely cannot be shared.

Context: one definition, three runtimes

MSW’s design already supports this. http.get(...) produces an environment-agnostic handler object; only the setup differs — setupWorker in the browser, setupServer in Node. The mistake is not in MSW but in the file layout: a single import chain from a browser entry to a Node entry pulls Node’s http module into the bundle, so people give up and duplicate.

Keeping the layout right is mostly about discipline in one file. The handler module must import nothing environment-specific: no node:fs, no window, no document. Everything it needs — fixtures, the store, helpers — must be equally loadable in both.

The shape that keeps the bundle clean A central handlers module imports only generated fixtures and a plain in-memory store, with no environment-specific dependencies. Three entry points import it: a browser module calling setupWorker, a Node module calling setupServer, and the test setup which imports the Node one. A crossed path shows the browser entry importing the Node entry, which is what pulls Node's http module into the bundle. mocks/handlers.ts no node:, no window, no document imports generated JSON + a plain Map mocks/browser.ts setupWorker — browser only mocks/server.ts setupServer — Node only src/main.tsx dynamic import of browser.ts vitest.setup.ts imports server.ts never this — it drags node:http into the bundle

Solution

1. Keep the handler module environment-free

// src/mocks/handlers.ts — importable from ANY runtime
import { http, HttpResponse } from 'msw';
import orders from './generated/orders.json';   // plain JSON, safe everywhere
import { store } from './store';                // a plain Map, no I/O

export const handlers = [
  http.get('https://api.example.com/orders', () =>
    HttpResponse.json({ items: orders.slice(0, 20), total: orders.length })
  ),

  http.get('https://api.example.com/orders/:id', ({ params }) => {
    const found = store.get(String(params.id)) ?? orders.find((o) => o.id === params.id);
    return found
      ? HttpResponse.json(found)
      : HttpResponse.json({ error: 'not_found', retryable: false }, { status: 404 });
  }),

  http.post('https://api.example.com/orders', async ({ request }) => {
    const body = (await request.json()) as { reference: string; totalMinor: number };
    const created = { id: body.reference, status: 'pending', totalMinor: body.totalMinor };
    store.set(created.id, created);
    return HttpResponse.json(created, { status: 201 });
  }),
];
// src/mocks/store.ts — deliberately boring; no fs, no localStorage
export interface Order { id: string; status: string; totalMinor: number; }

export const store = new Map<string, Order>();
export const resetStore = () => store.clear();

Using a plain Map rather than anything persistent is what keeps this file loadable everywhere. The moment the store reaches for node:fs to persist, or for localStorage to survive a reload, it stops being shareable — and the fix is a second module, not a conditional import.

2. Split the entry points

// src/mocks/browser.ts — imported ONLY from browser code
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);
// src/mocks/server.ts — imported ONLY from Node code
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

Two files, four lines each, and the bundler never sees msw/node from a browser entry. Enforce it with a lint rule so the boundary survives contact with a hurry:

// eslint.config.js
export default [
  {
    files: ['src/**/*.{ts,tsx}'],
    ignores: ['src/mocks/server.ts', 'vitest.setup.ts', 'src/**/*.test.*'],
    rules: {
      'no-restricted-imports': ['error', {
        paths: [
          { name: 'msw/node', message: 'Node-only. Import src/mocks/server.ts from test setup instead.' },
          { name: './server', message: 'Node-only entry point — use ./browser in application code.' },
        ],
      }],
    },
  },
];

3. Accept that state does not cross the boundary

The browser worker and the Node server are different processes with different module instances. An order created through the browser is not in the Node store, and no amount of shared handler code changes that.

Three approaches, in decreasing order of preference:

Seed both sides identically. Generated fixtures loaded from the same JSON give both environments the same starting data, which covers most needs.

Give one side ownership. In a server-rendered application, let the Node side own the store and have the browser call through to it rather than keeping its own copy.

Externalise the store. Where genuinely shared state is required — an end-to-end test that creates through the UI and reads through the server — move it behind a small HTTP admin endpoint that both sides call. That is the arrangement running WireMock in Docker Compose gets for free by being a separate process.

Shared handlers, separate state The browser worker and the Node server both load the same handler module but hold separate in-memory stores, so a record created in one is invisible to the other. Three reconciliation options are listed: seed both from the same generated fixtures, give one side ownership and have the other call through, or externalise the store behind an admin endpoint both call. Browser worker store: { ord_A } created through the UI Node server store: { } — empty never saw the create no shared memory 1 · Seed both from the same generated fixtures covers most needs; no cross-process machinery at all 2 · One side owns the store, the other calls through natural for a server-rendered application 3 · Externalise behind an admin endpoint both call what a standalone mock server gives you for free

Verification

# The browser bundle contains no Node built-ins
npm run build && grep -rlE "require\((['\"])(node:)?(http|https|fs)\1\)" dist/assets | wc -l   # 0

# The same handler set answers in both environments
npx vitest run src/mocks/handlers.contract.test.ts     # Node side
npx playwright test e2e/handlers.spec.ts               # browser side

A small contract spec that asserts the same three endpoints from both sides is worth keeping. It is the only thing that catches a handler which happens to work in Node — say, one that relies on Buffer — and fails silently in the browser.

Gotchas and edge cases

  • import { setupServer } from 'msw/node' inside a test utility leaks. A shared test helper imported by both unit and browser-mode specs pulls msw/node into the browser build, and Vite’s error points at the helper rather than the import that caused it. Keep environment-specific imports in files whose names make the environment obvious.

  • Buffer, process and __dirname are Node-only. They are easy to reach for in a handler that decodes a cursor or reads an env var. Use TextEncoder, atob/btoa and import.meta.env instead, all of which work in both runtimes.

  • A handler importing a huge fixture file ships it to production. Even behind a dynamic import, a generated fixture set of several megabytes ends up as a separate chunk that a curious visitor can fetch. Keep fixtures out of the production build entirely by gating the dynamic import on the mode flag, as swapping mock and live APIs with env vars describes.


Imports that break sharing Three import categories that break a handler module's portability: Node built-ins, DOM globals, and anything that reads the filesystem at import time. Each is paired with the portable alternative that does the same job in both runtimes. Node built-ins Buffer, node:fs, process TextEncoder, generated JSON, import.meta.env DOM globals window, document, localStorage a plain in-memory store Filesystem reads at import a fixture builder that loads from disk import the generated JSON directly A lint rule on the handler module catches all three at the moment they are added rather than at the next build.

Keeping the boundary from eroding

The split described here is easy to establish and easy to lose, because every erosion has a good local reason at the time.

The most common is a helper. Someone writes a test utility that both a unit spec and a browser spec import, and it happens to import the Node entry point. The browser build now pulls in msw/node, and the error message points at the helper rather than at the import that caused it. Naming files so the environment is obvious — server.ts, browser.ts — makes the mistake visible in review; a lint rule makes it visible in the editor.

The second is a fixture builder. A handler needs data, the data lives in a file, and reading a file needs node:fs. The fix is to generate the fixture as JSON at build time and import it, which works identically in both runtimes and has the additional benefit of making the data reviewable.

The third is a convenience global. process.env inside a handler is the usual one. It works under Node, is undefined in the browser, and produces behaviour that differs by environment without any error — which is the worst of the three because nothing fails.

The fourth is a store that grows a persistence feature. An in-memory Map is portable; the same store with a “save to disk between runs” option is not, and the option is always added for a good reason.

A single guard covers all four: a test that imports the handler module in a browser-like environment and asserts it loads. It runs in milliseconds, it fails the moment any environment-specific import is added, and it names the module rather than the eventual build error. That one spec is worth more than any amount of documentation about the boundary.

← Back to Advanced MSW Handler Patterns