msw
Mock Service Worker intercepts HTTP requests at the network layer instead of monkey-patching fetch or axios. In the browser it registers a real Service Worker that catches outgoing requests and answers them from handlers you write; in Node it swaps the low-level http, https, XMLHttpRequest, and fetch internals through the @mswjs/interceptors package. The practical consequence is that your application code never learns it is being mocked: no injected client, no adapter, no conditional imports. You write Express-style route handlers once (http.get, graphql.query, ws.link) and reuse the same file for Jest and Vitest unit tests, Playwright and Cypress end-to-end runs, Storybook stories, and local development against an API that does not exist yet.
The default choice for API mocking in JavaScript, because network-level interception means the same handlers work in tests, Storybook, and the browser, and your app code stays free of test-only branches. Budget half a day for the Jest and Service Worker setup, and pair it with generated types or contract tests so your mocks cannot quietly stop matching the real API.
Use it if
- You want one set of mocks shared by unit tests, component tests, Storybook, and local dev, instead of a jest.mock file, a Cypress intercept file, and a dev proxy that all drift apart
- Your tests currently stub fetch or axios and you keep shipping bugs the tests could not catch, because the stub skipped your interceptors, retry logic, and response parsing
- You need to develop against an API that is not built yet, and you want the browser Network tab to show real request and response entries you can inspect
- You mock more than plain REST: msw handles GraphQL operations by name and WebSocket links through the same handler model
- You want per-test overrides: server.use() adds a handler for one test and resetHandlers() clears it, so the error case and the happy path live next to each other
- You only test Node code and never touch a browser: nock is smaller, has no Service Worker file to manage, and records real traffic to fixtures, which msw deliberately does not do
- Nobody verifies the mocks against the real API: msw makes it easy for handlers to keep returning a field the backend deleted six months ago, and your green test suite tells you nothing about that; if contract drift is the actual risk, you want Pact or generated types from an OpenAPI or GraphQL schema
- Your test environment fights the fetch globals: msw v2 requires Node 18+ and real Request, Response, and TextEncoder objects, which jest-environment-jsdom does not provide, so Jest users spend an afternoon on custom environments and transformIgnorePatterns before a single test runs
- You are still on msw v1: v2 replaced the entire res(ctx.json()) resolver signature with returning HttpResponse, moved setupWorker and setupServer to msw/browser and msw/node, and every tutorial and Stack Overflow answer you find will be for the version you are not on
- You want a stateful fake backend with relationships and persistence: msw handlers are stateless functions, and you either bolt on @mswjs/data or accept that Mirage JS already ships an ORM, factories, and seeds
- You need to mock requests your page did not make: the Service Worker only sees same-scope requests from the page, so cross-origin iframes, other workers, and anything outside the registered scope stay unmocked
Setup reality
npm install msw --save-dev is the easy half. Browser usage also needs npx msw init ./public --save, which copies mockServiceWorker.js into your static directory; that file is versioned, so every msw upgrade prints a console warning until you re-run init, and forgetting it in CI or a Docker build gives you a 404 and silently unmocked requests. Node usage needs a setup file wiring beforeAll(() => server.listen()), afterEach(() => server.resetHandlers()), and afterAll(() => server.close()), and you almost always want onUnhandledRequest: 'error' so a typo'd URL fails loudly instead of hitting the real internet. Vitest works out of the box with the node or jsdom environment. Jest is the rough path: jsdom lacks the fetch primitives msw v2 needs, so you end up on a custom test environment or polyfills, and the package's exports map (msw/node, msw/browser, msw/native) trips older Jest resolvers until you set testEnvironmentOptions customExportConditions. TypeScript is a peer dependency but optional, and it wants 4.8 or newer.
Patterns
Wire setupServer into a test runnernode-test-setup
// vitest.setup.ts (add to test.setupFiles in vitest.config.ts)
import { afterAll, afterEach, beforeAll } from 'vitest'
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
export const server = setupServer(
http.get('https://api.example.com/user', () =>
HttpResponse.json({ id: 1, name: 'Ada' }),
),
)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())onUnhandledRequest: 'error' is the setting that earns its keep. The default is 'warn', which lets a typo'd URL escape to the real internet and turns a fast unit test into a flaky network test.
Start the Service Worker in the browserbrowser-worker-setup
// terminal, once per project:
// npx msw init ./public --save
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)
// src/main.tsx
if (import.meta.env.DEV) {
const { worker } = await import('./mocks/browser')
await worker.start({ onUnhandledRequest: 'bypass' })
}
renderApp()await worker.start() before rendering, or the first requests race the worker registration and hit the real network. Re-run msw init after every msw upgrade; a stale mockServiceWorker.js logs a version warning and can behave incorrectly.
Read path params, query string, and request bodyrest-handler-params-and-body
import { http, HttpResponse } from 'msw'
http.post('/api/posts/:postId/comments', async ({ request, params }) => {
const { postId } = params
const page = new URL(request.url).searchParams.get('page') ?? '1'
const body = await request.json()
return HttpResponse.json(
{ id: 'c1', postId, page, text: body.text },
{ status: 201 },
)
})request is a standard Request, so the body is read with await request.json() or request.formData(), and query params come off new URL(request.url), not a params object. Relative paths like /api/posts resolve against the page origin in the browser and need an absolute URL in Node.
Override a handler for a single testper-test-override
import { http, HttpResponse } from 'msw'
import { server } from '../vitest.setup'
test('shows an error banner when the API fails', async () => {
server.use(
http.get('https://api.example.com/user', () =>
HttpResponse.json({ message: 'boom' }, { status: 500 }),
),
)
render(<Profile />)
expect(await screen.findByRole('alert')).toBeInTheDocument()
})server.use() prepends the handler so it wins over the default. It stays active until resetHandlers(), which is why the afterEach in the setup file is not optional; without it, test order starts changing results.
Simulate network failures, timeouts, and empty bodiesnetwork-error-and-status
import { http, HttpResponse, delay } from 'msw'
http.get('/api/flaky', () => HttpResponse.error()) // TypeError: Failed to fetch
http.get('/api/slow', async () => {
await delay(3000)
return HttpResponse.json({ ok: true })
})
http.get('/api/hang', async () => {
await delay('infinite') // never responds
})
http.delete('/api/item/:id', () => new HttpResponse(null, { status: 204 }))HttpResponse.error() produces a real rejected fetch, not a 500, which is the only way to test your offline path. delay('infinite') is how you test loading spinners and abort handling; combine it with fake timers carefully, since delay uses real timers by default.
Return a different response on the second callone-time-and-sequenced-responses
import { http, HttpResponse } from 'msw'
server.use(
http.get('/api/job', () => HttpResponse.json({ status: 'pending' }), {
once: true,
}),
http.get('/api/job', () => HttpResponse.json({ status: 'done' })),
)The { once: true } handler is consumed after one match, then the next matching handler takes over, which is how you test polling and retry logic. resetHandlers() restores consumed one-time handlers too.
Mock GraphQL queries and mutations by operation namegraphql-operations
import { graphql, HttpResponse } from 'msw'
const api = graphql.link('https://api.example.com/graphql')
export const handlers = [
api.query('GetUser', ({ variables }) =>
HttpResponse.json({ data: { user: { id: variables.id, name: 'Ada' } } }),
),
api.mutation('DeleteUser', () =>
HttpResponse.json({
errors: [{ message: 'Not authorized' }],
}),
),
]Matching is by operation name, so an anonymous query never matches; name your operations. GraphQL errors are a 200 response with an errors array, not an HTTP status, which is the mistake people make when testing error states.
Mock a WebSocket connectionwebsocket-mocking
import { ws } from 'msw'
const chat = ws.link('wss://chat.example.com')
export const handlers = [
chat.addEventListener('connection', ({ client }) => {
client.send(JSON.stringify({ type: 'welcome' }))
client.addEventListener('message', (event) => {
const msg = JSON.parse(event.data.toString())
client.send(JSON.stringify({ type: 'echo', text: msg.text }))
})
}),
]client.send() pushes to your app; server.connect() inside the same handler opens the real upstream socket if you want to observe and modify real traffic instead of faking it. event.data is a string or Buffer depending on environment, so coerce before parsing.
Let some requests reach the real serverpassthrough-and-bypass
import { http, passthrough, bypass, HttpResponse } from 'msw'
// send this route to the real network untouched
http.get('/assets/*', () => passthrough())
// call the real API, then modify the response
http.get('https://api.example.com/user', async ({ request }) => {
const real = await fetch(bypass(request))
const data = await real.json()
return HttpResponse.json({ ...data, plan: 'enterprise' })
})bypass(request) marks the outgoing request so msw does not intercept its own call; plain fetch(request) inside a handler causes infinite recursion. Requests without a matching handler already pass through, so passthrough() is for carving exceptions out of a broad matcher.
Type params, request body, and response bodytyped-handlers
import { http, HttpResponse } from 'msw'
type Params = { userId: string }
type Body = { name: string }
type Response = { id: string; name: string }
http.put<Params, Body, Response>('/api/users/:userId', async ({ request, params }) => {
const { name } = await request.json()
return HttpResponse.json({ id: params.userId, name })
})The generic order is path params, request body, response body. Without it params values are typed as string or string[] and request.json() returns unknown, so most teams generate these types from their OpenAPI or GraphQL schema and get drift detection for free.
Assert on requests through the life-cycle eventsassert-request-was-made
import { server } from '../vitest.setup'
test('sends the auth header', async () => {
const seen: Request[] = []
server.events.on('request:start', ({ request }) => seen.push(request))
await callApi()
expect(seen).toHaveLength(1)
expect(seen[0].headers.get('authorization')).toBe('Bearer token')
})
afterEach(() => server.events.removeAllListeners())msw has no built-in spy assertions on purpose; life-cycle events are the supported way. Remove listeners between tests or they accumulate across the whole file. Clone the request before reading its body, since the body stream can only be consumed once.
Scope mocks to one request in a running Node serverscoped-handlers-in-a-server
import express from 'express'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
const app = express()
const mocks = setupServer()
mocks.listen()
app.get('/checkout', mocks.boundary(async (req, res) => {
mocks.use(
http.get('https://api.stripe.com/v1/checkout/sessions/:id', ({ params }) =>
HttpResponse.json({ id: params.id, status: 'open' }),
),
)
res.json(await handleCheckout(req))
}))boundary() puts the handlers in async local storage so concurrent requests do not see each other's overrides. Without it, mocks.use() inside a request handler is global and two parallel requests will fight over which mock is active.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| nock | npm | Node-only testing where you also want to record real HTTP traffic to fixtures and replay it; no Service Worker, no browser support. |
| miragejs | npm | You want a stateful fake backend with an ORM, factories, and seed data rather than stateless request handlers. |
| @pact-foundation/pact | npm | The risk you care about is mocks drifting from the real API, so you need consumer-driven contracts verified against the actual provider. |
| @mswjs/data | npm | You are keeping msw but need a data layer: this is the companion package that adds models, relationships, and generated CRUD handlers. |