MSW 3.0 shipped on 2026-09-28. What you remember is most likely MSW 2, and several v2 patterns no longer compile or behave differently. Check the installed version first:
node -p "require('./node_modules/msw/package.json').version"
If it is 3.x, follow this file. When unsure about an API, read the types in node_modules/msw/lib/**/*.d.ts instead of guessing.
The v2 habits that break in v3
| v2 (wrong in v3) | v3 |
|---|---|
import { graphql } from 'msw' |
import { graphql } from 'msw/graphql' |
graphql.query('GetUser', …) |
graphql.link(url).query('GetUser', …). A link is required; there is no top-level graphql.query / graphql.mutation |
server.listen({ onUnhandledRequest: 'error' }) |
server.listen({ onUnhandledFrame: 'error' }). Same for worker.start() |
server.events.on('connection', …) |
server.events.on('websocket:connection', ({ url }) => …). url is a URL |
worker.stop() |
await worker.stop() (returns a Promise) |
delay() resolves under fake timers |
MSW no longer works around fake timers. Advance them: await vi.advanceTimersByTimeAsync(ms) |
request.headers.get('cookie') |
Use the cookies resolver argument: ({ cookies }) => cookies.session |
require('msw') |
ESM only. Node ≥ 22.12, TypeScript ≥ 5.9 |
handleRequest(), msw/native |
Removed. Use defineNetwork() from msw/experimental, or @msw/react-native |
ws.link(url).addEventListener('connection', …) is unchanged. Only the server-level life-cycle event was renamed.
Imports
import { http, HttpResponse, delay, passthrough, bypass, ws, sse } from 'msw'
import { graphql } from 'msw/graphql'
import { setupServer } from 'msw/node' // Node: Vitest, Jest
import { setupWorker } from 'msw/browser' // browser
Patterns
HTTP handlers
export const handlers = [
http.get('https://api.example.com/users/:id', ({ params }) =>
HttpResponse.json({ id: params.id }),
),
http.post('https://api.example.com/todos', async ({ request }) => {
const body = (await request.json()) as { title: string }
return HttpResponse.json({ id: 1, ...body }, { status: 201 })
}),
http.get('https://api.example.com/offline', () => HttpResponse.error()), // network error
http.get('https://api.example.com/flaky', () => new HttpResponse(null, { status: 503 }), { once: true }),
]
GraphQL
import { HttpResponse } from 'msw'
import { graphql } from 'msw/graphql'
const api = graphql.link('https://api.example.com/graphql')
export const graphqlHandlers = [
api.query<{ user: { id: string } }, { id: string }>('GetUser', ({ variables }) =>
HttpResponse.json({ data: { user: { id: variables.id } } }),
),
api.mutation('RenameUser', () =>
HttpResponse.json({ errors: [{ message: 'Name must not be empty' }] }),
),
]
api.operation(resolver) matches any operation. api.subscription(name, resolver) is new in v3.
Vitest setup
// vitest.setup.ts, registered in vitest.config.ts → test.setupFiles
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './src/mocks/node'
beforeAll(() => server.listen({ onUnhandledFrame: 'error' }))
afterEach(() => server.resetHandlers()) // drop server.use() overrides between tests
afterAll(() => server.close())
Override a handler for one test with server.use(http.get(…)). onUnhandledFrame accepts 'bypass' | 'warn' | 'error' or a callback ({ frame, defaults }) => …. Inside a callback, defaults.error() only prints; it does not fail the request.
Delays with fake timers
vi.useFakeTimers()
const pending = loadTodos() // handler does: await delay(2000)
await vi.advanceTimersByTimeAsync(2000)
await pending
Life-cycle events
server.events.on('request:unhandled', ({ request }) => log(`${request.method} ${request.url}`))
server.events.on('websocket:connection', ({ url }) => urls.push(url.href))
HTTP events: request:start, request:match, request:unhandled, request:end, response:mocked, response:bypass. WebSocket events: websocket:connection, websocket:error.
WebSocket
const chat = ws.link('wss://chat.example.com')
export const chatHandlers = [
chat.addEventListener('connection', ({ client }) => {
client.addEventListener('message', (event) => client.send(`echo:${event.data}`))
}),
]
Vite dev server (new in v3): add msw() from msw/vite to plugins, then in the app entry:
if (import.meta.env.DEV) {
const { network } = await import('virtual:msw')
const { handlers } = await import('./mocks/handlers')
network.configure({ handlers })
await network.enable()
}
Best practices
From the official guide (mswjs.io/docs/guides/best-practices). Apply them by default.
-
Happy paths in shared handlers, edge cases per test.
src/mocks/handlers.tsdescribes the successful responses. Errors, empty states and slow responses go into the test that needs them, viaserver.use(). Pair that withafterEach(() => server.resetHandlers()). Don't add an "error mode" flag to the shared handlers. -
Don't assert on requests. No spies inside resolvers, no captured request bodies, no "the handler was called" checks. Make the handler behave like the real API instead: validate the request and return an error (such as 400) when it is wrong. Then assert on what the code under test returns or renders. Use life-cycle events only for one-way requests that have no visible effect, like analytics.
http.post<never, { title?: string }, Todo | { error: string }>(url, async ({ request }) => { const { title } = await request.json() if (!title) return HttpResponse.json({ error: 'title is required' }, { status: 400 }) return HttpResponse.json({ id: 1, title, done: false }, { status: 201 }) }) // test: expect(await createTodo('x')).toEqual({ id: 1, title: 'x', done: false }) -
Type handlers with generics, not casts.
http.post<Params, RequestBody, ResponseBody>(path, resolver)typesparams,await request.json()andHttpResponse.json(). For GraphQL, useapi.query<Query, Variables>(). Avoid(await request.json()) as X. -
Group large APIs by domain. Use
mocks/handlers/users.ts,todos.tsand so on, each exportinghandlers, and compose them in one place:export const handlers = [...userHandlers, ...todoHandlers]. -
Put repeated logic in a higher-order resolver instead of copying it into every handler:
function withAuth<P extends PathParams, B extends DefaultBodyType>( resolver: HttpResponseResolver<P, B>, ): HttpResponseResolver<P, B> { return (info) => info.request.headers.get('Authorization') === 'Bearer test-token' ? resolver(info) : new HttpResponse(null, { status: 401 }) } http.get(url, withAuth(() => HttpResponse.json([]))) -
Switchable scenarios (demos, local dev): build a map from scenario name to override handlers, and put the chosen scenario before the base handlers, because the left-most handler wins.
setupWorker(...(scenarios[name] ?? []), ...handlers). -
Browser worker: run
npx msw init public --save.--saverecordsmsw.workerDirectoryinpackage.json, so upgrades regenerate the script. Commitpublic/mockServiceWorker.js. Don't hand-write or copy the worker file. -
Fail on unknown traffic in tests:
server.listen({ onUnhandledFrame: 'error' }). -
Close a server once. In v3, calling
server.close()on a server that is already closed throws "already disabled".
Before you finish
- Run the project's typecheck (
npx tsc --noEmit). Most v2 habits in the table above show up as type errors. - Run the tests you touched with
vitest run(not watch mode).
More before/after examples: references/v2-to-v3.md.