Mock Service Worker (MSW) Setup
This page covers everything required to install, configure, and verify MSW 2.x in both browser and Node.js environments — from worker script placement to CI pipeline integration.
Prerequisites
- Node.js 18 or later installed (
node --version) - A project using
npm,pnpm, oryarnwith apackage.json - A statically served public directory (
public/,static/, or equivalent) accessible at the origin root during development - Basic familiarity with the request interception pattern that MSW builds on
- Vitest ≥ 1.x or Jest ≥ 29.x if you intend to run the Node interceptor in tests
- (Optional) Next.js 13+ or Vite 4+ for framework-specific wiring in Phase 2
How MSW intercepts requests
MSW operates at the network boundary rather than inside your application’s HTTP client layer. Understanding the two execution paths before writing a single handler prevents the most common setup mistakes.
Both paths share the same handlers.ts file. Only the setup entry point (browser.ts vs node.ts) differs, which is what makes MSW’s handler authoring consistent across environments.
Phase 1 — Install and register the worker script
Install the package and generate the browser worker file in one command:
npm install msw --save-dev
npx msw init public/ --save
npx msw init writes mockServiceWorker.js into your chosen public directory and records the path in package.json under "msw": { "workerDirectory": "public" }. The file must be served at https://localhost:<port>/mockServiceWorker.js without any filename hash or fingerprint — verify this before proceeding.
Create the shared handler list at src/mocks/handlers.ts:
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Ada Lovelace', role: 'engineer' },
{ id: 2, name: 'Grace Hopper', role: 'engineer' },
])
}),
http.post('/api/login', async ({ request }) => {
const body = await request.json() as { username: string; password: string }
if (body.username === 'test' && body.password === 'correct') {
return HttpResponse.json({ token: 'mock-jwt-token' }, { status: 200 })
}
return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}),
http.get('/api/products/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Widget Pro',
price: 49.99,
inStock: true,
})
}),
]
Create the browser setup at src/mocks/browser.ts:
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)
Create the Node setup at src/mocks/node.ts:
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
Phase 2 — Environment gating and framework wiring
Vite and React
Gate the browser worker behind the Vite DEV flag in your application entry point (src/main.tsx):
async function enableMocking(): Promise<void> {
if (!import.meta.env.DEV) return
const { worker } = await import('./mocks/browser')
await worker.start({
onUnhandledRequest: 'warn',
serviceWorker: {
url: '/mockServiceWorker.js',
},
})
}
enableMocking().then(() => {
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
})
The await worker.start() call returns a Promise that resolves once the Service Worker is active. Rendering after this promise prevents a race condition where the first network request fires before interception is ready — a common source of intermittent failures in development.
Next.js App Router
The App Router runs server components on Node.js and client components in the browser. Because of this split, the Next.js App Router configuration for MSW is handled on a dedicated page with full middleware and server component coverage.
Environment variables for staging
Use NEXT_PUBLIC_ENABLE_MOCKS, VITE_ENABLE_MOCKS, or an equivalent public env var to activate mocks in staging without requiring NODE_ENV=development:
// src/main.tsx (Vite)
async function enableMocking(): Promise<void> {
if (import.meta.env.VITE_ENABLE_MOCKS !== 'true') return
const { worker } = await import('./mocks/browser')
await worker.start({ onUnhandledRequest: 'warn' })
}
Set the variable in your CI or staging deploy config rather than committing it to .env. This aligns with the network layer abstraction principle of switching mock layers without changing application code.
Phase 3 — Test suite integration and CI pipeline wiring
The Node interceptor fits naturally into any test runner that supports global setup hooks.
Vitest setup
Create a Vitest setup file at src/setupTests.ts and reference it in vite.config.ts:
// src/setupTests.ts
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './mocks/node'
beforeAll(() =>
server.listen({
onUnhandledRequest: 'error', // fail the test if an unhandled route is called
})
)
afterEach(() => server.resetHandlers()) // prevent handler leaks between tests
afterAll(() => server.close())
// vite.config.ts (relevant excerpt)
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/setupTests.ts'],
globals: true,
},
})
Jest setup
// jest.setup.ts
import { server } from './src/mocks/node'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// jest.config.json (relevant keys)
{
"setupFilesAfterFramework": ["<rootDir>/jest.setup.ts"],
"testEnvironment": "node"
}
Per-test handler overrides
Override a single handler in an individual test without affecting the global list:
import { http, HttpResponse } from 'msw'
import { server } from '../mocks/node'
test('displays error state when API returns 500', async () => {
server.use(
http.get('/api/users', () =>
HttpResponse.json({ error: 'Internal Server Error' }, { status: 500 })
)
)
render(<UserList />)
expect(await screen.findByText('Something went wrong')).toBeInTheDocument()
})
// server.resetHandlers() in afterEach removes the override automatically
GitHub Actions CI
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test -- --run
env:
NODE_ENV: test
# No VITE_ENABLE_MOCKS needed — the Node interceptor is always used in tests
onUnhandledRequest: 'error' in the Node interceptor causes any unmocked route to throw, which immediately fails the test and surfaces missing handler coverage in CI rather than silently returning undefined.
This CI approach sits alongside dockerized mock environments when your test suite also needs external services — the two approaches are complementary, not mutually exclusive. For teams evaluating whether to use client-side interception or a dedicated mock server, the proxy vs inline mocking strategies comparison covers the decision criteria in depth.
Verification steps
Run through these after completing all three phases to confirm correct setup:
-
ls public/mockServiceWorker.js— file exists in the static directory -
npm run dev→ open browser DevTools → Application → Service Workers:mockServiceWorker.jsshows as “activated and running” - Open Network tab, trigger a request to
/api/users— the row shows(ServiceWorker)in the Initiator column, not a real network request -
npm test -- --run 2>&1 | grep '\[MSW\]'— output includes[MSW] Mocking enabled. - Add a handler for a route that does not exist in handlers.ts, call it in a test — the test fails with
[MSW] Error: captured a request without a matching request handler - Run
cat package.json | grep -A2 '"msw"'— confirmsworkerDirectoryis set
Troubleshooting
Failed to register a ServiceWorker: The path of the provided scope ('/') is not under the max scope allowed
Cause: The server response for mockServiceWorker.js does not include a Service-Worker-Allowed: / header, or the file is served from a subdirectory (e.g., /static/mockServiceWorker.js) rather than the origin root.
Fix: Move mockServiceWorker.js to the root of your public directory and re-run npx msw init public/ --save. If you must serve from a subdirectory, configure your dev server to add the Service-Worker-Allowed: / response header for that file path.
[MSW] Warning: intercepted a request without a matching request handler
Cause: A request was made to a route not covered by any entry in handlers.ts. In development this is a warning; in tests configured with onUnhandledRequest: 'error' it becomes a thrown exception.
Fix: Either add a handler for the route, or add an explicit http.get('/path/to/resource', passthrough()) to pass it through to the real network. For third-party domains (analytics, CDN), use onUnhandledRequest: 'bypass' in development only.
TypeError: Cannot read properties of undefined (reading 'start') (browser)
Cause: The dynamic import of ./mocks/browser resolved before the module was ready, or the module exports are mismatched. Usually caused by circular imports between handlers.ts and the module under test.
Fix: Ensure handlers.ts imports nothing from the application source tree — only from msw. Move any shared fixture data to a separate src/mocks/fixtures/ directory imported by both handlers and tests.
Tests pass individually but fail when run together
Cause: Handler state is leaking between tests. The server.resetHandlers() call in afterEach is missing or afterEach is not being imported from the correct test runner module.
Fix: Verify afterEach is imported from vitest (not from @jest/globals or another runner) and that server.resetHandlers() is inside the afterEach callback, not afterAll. Use server.restoreHandlers() only when you want to undo server.use() overrides without resetting to the initial list.
mockServiceWorker.js returns 404 in Vite dev server
Cause: Vite’s publicDir defaults to public/ relative to the project root, but the msw init command was run from a subdirectory or publicDir was overridden in vite.config.ts.
Fix: Run npx msw init <your-actual-publicDir> --save with the path matching vite.config.ts’s publicDir setting. Confirm with curl http://localhost:5173/mockServiceWorker.js — the response should be JavaScript, not a 404 HTML page.
When to advance
This setup is complete and stable when all of the following hold:
- The browser Service Worker appears as “activated” in DevTools on every cold page load
- Every test that touches an API route passes via the Node interceptor without reaching the real network
onUnhandledRequest: 'error'is set in the test environment and CI builds fail when a handler is missing- Per-test handler overrides via
server.use()work correctly and are cleaned up byafterEach - The worker script is excluded from your bundler’s asset hashing pipeline
At this point you can safely move to advanced MSW handler patterns — covering stateful scenarios, response streaming, and network condition simulation — without risking regressions in the base setup.
FAQ
Does MSW require any backend to be running during development?
No. MSW intercepts requests entirely in the browser or Node process. No proxy, no sidecar, and no dockerized mock environment is required for MSW to function. The Service Worker operates offline-capable and does not forward requests to any external host unless you explicitly call passthrough().
Can MSW intercept WebSocket connections?
MSW 2.x added ws() handler support for WebSocket interception in the browser. The Node interceptor does not natively intercept WebSocket upgrade requests — use the ws() handler in a browser test context via Playwright, or wire in a separate in-process WebSocket server for Node-based tests.
How do I share handlers between MSW and WireMock for a hybrid stack?
MSW and WireMock standalone configuration can coexist in the same project: MSW handles browser-side fetch interception for frontend tests, while WireMock handles HTTP-level contract verification for backend integration tests. The cleanest boundary is to define your API contract in OpenAPI and generate both MSW handler fixtures and WireMock stubs from the same spec file.
Does enabling mocks slow down my production build?
No, provided you gate the worker initialisation correctly. The import('./mocks/browser') dynamic import is tree-shaken out of production builds by Vite and most webpack configurations because the branch (import.meta.env.DEV === false) is statically false at build time. Verify with npm run build -- --report and confirm msw does not appear in your production bundle analysis.
Related
- How to Configure MSW for Next.js Apps — App Router middleware constraints and server component data fetching
- Advanced MSW Handler Patterns — stateful scenarios, streaming responses, and network error simulation
- Request Interception Patterns — foundational theory behind how client-side interception works
- Proxy vs Inline Mocking Strategies — when to use MSW vs a standalone mock server
- Mock Lifecycle Management — aligning mock startup and teardown with environment lifecycles
← Back to Tool-Specific Implementation & Setup