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.
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.
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 pullsmsw/nodeinto 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,processand__dirnameare Node-only. They are easy to reach for in a handler that decodes a cursor or reads an env var. UseTextEncoder,atob/btoaandimport.meta.envinstead, 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.
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.
Related
- Writing Custom MSW Response Resolvers — the resolver patterns this shared module holds
- How to Configure MSW for Next.js Apps — the same split applied to a framework with both runtimes
- Fixing an MSW Service Worker 404 in Vite — the browser entry point’s most common failure
← Back to Advanced MSW Handler Patterns