msw review
MSW 2.15.0 answers real application requests from test-owned handlers. A browser build registers a Service Worker, while Node uses the project's interceptor layer; your code still calls fetch, Axios, or a GraphQL client. That makes one handler collection usable in component tests, integration tests, Storybook, and local browser work. Version 2.15.0 finalizes a server-sent event response when its stream ends, so cleanup can run at completion. Our full-package browser build measured 404.7 KB minified and 123.4 KB gzipped, which is too much to ship accidentally in production code.
Our MSW 2.15.0 install used 25 MB across 60 packages, and a full browser import reached 123.4 KB gzipped, so keep its entry points in test or development code. Install it when shared network handlers are worth the worker setup; choose contract testing when response drift is the main risk.
We installed it
| Install | ✓ · 5.9s | 60 packages on disk · 25 MB |
| Import | ✓ | ESM import works · require() works · CommonJS package with exports map |
| Browser | 123.4 KB | gzipped (404.7 KB minified), bundled with esbuild |
| Types | ✓ | TypeScript types bundled |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does msw install cleanly?
Yes. In a fresh container with an empty cache, npm install msw finished in 6 seconds, leaving 60 packages and 25 MB on disk. npm audit reported no known vulnerabilities.
How much does msw add to a browser bundle?
123.4 KB gzipped (404.7 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does msw work with both ESM and CommonJS?
Yes. Both import 'msw' and require('msw') worked in Node 22 in our run. The package is published as CommonJS with an exports map.
Does msw include TypeScript types?
Yes, type declarations ship inside the package, so no @types install is needed.
msw or nock: which should you use?
nock: Use it for Node-only HTTP interception and optional request recording. Our MSW 2.15.0 install used 25 MB across 60 packages, and a full browser import reached 123.4 KB gzipped, so keep its entry points in test or development code.
When should you not use msw?
A passing test must prove compatibility with the provider; handwritten MSW payloads can drift without Pact or schema checks
Use it if
- Browser previews and Node tests need to share the same HTTP or GraphQL handlers
- You want mocked browser traffic to remain visible in the Network panel
- Tests need per-case delays, network errors, or temporary response overrides
- Your suite exercises REST, GraphQL, WebSocket, or server-sent event clients
- A passing test must prove compatibility with the provider; handwritten MSW payloads can drift without Pact or schema checks
- Every test runs in Node and you also want recorded HTTP fixtures; nock is narrower and supports recording
- Your runtime is older than Node 18, which MSW 2.15.0 excludes
- Your suite still uses v1 resolver calls such as res(ctx.json()); v2 uses HttpResponse and separate browser or Node imports
- You need models, factories, and lasting in-memory relationships; MirageJS owns that state while MSW leaves it in handler code
Setup reality
We installed MSW 2.15.0 in 5.9 seconds on Node 22. It left 60 packages occupying 25 MB, and npm audit reported 0 known vulnerabilities. The package has 18 direct dependencies and 1 peer dependency, with 7,920 KB unpacked. Bundled TypeScript types were present. Its CommonJS package and exports map loaded through require() and ESM import. Our broad browser import produced 404.7 KB minified and 123.4 KB gzipped.
Browser setup needs npx msw init pointed at the public-assets directory. Commit mockServiceWorker.js, serve it under a scope that covers the page, and regenerate it when the package tells you the worker is stale. Await worker.start() before the app sends its first request. Node tests instead import msw/node and should call listen once, resetHandlers after each case, and close once. No credential is required unless a handler itself calls a protected service.
Unhandled requests pass through by default, which can turn a typo into live network traffic. Use onUnhandledRequest: 'error' in tests that must stay offline. server.use() adds runtime handlers until resetHandlers() clears them. Request bodies follow the standard Request stream rules, so clone a request before two consumers read it. A browser Service Worker controls only URLs and pages under its registration scope.
Concurrent Node work can share handler changes unless server.boundary() or separate processes isolate them. Remove lifecycle listeners after each assertion because they accumulate on the server object. Fake timers can stall delay(). WebSocket and SSE clients also leave open handles if a test never closes them. The 2.15.0 SSE finalization change fixes cleanup at stream end, but it cannot end a client connection that the test deliberately leaves open.
Patterns
Start and stop around Node tests node-lifecycle
import { afterAll, afterEach, beforeAll } from 'vitest';
import { setupServer } from 'msw/node';
const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());resetHandlers() removes overrides added by server.use(). Error mode prevents an unmatched URL from reaching the network.
Start the worker before rendering browser-worker
// once: npx msw init ./public --save
import { setupWorker } from 'msw/browser';
const worker = setupWorker(...handlers);
await worker.start();
renderApp();The generated worker file must be served inside the page's scope. Waiting for start prevents early requests from escaping.
Handle a REST update rest-json
import { http, HttpResponse } from 'msw';
http.put('/api/users/:id', async ({ request, params }) => {
const body = await request.json();
return HttpResponse.json({ id: params.id, ...body });
});request is the standard Request object. Read query parameters through new URL(request.url).searchParams.
Override one response test-override
server.use(
http.get('/api/account', () =>
HttpResponse.json({ message: 'down' }, { status: 503 })
)
);server.use() gives the new handler priority and keeps it active until resetHandlers().
Simulate a failed connection network-error
server.use(http.get('/api/account', () => HttpResponse.error()));HttpResponse.error() rejects at the network layer. An HTTP 500 still produces a normal Response object.
Delay a response delayed-response
import { delay, http, HttpResponse } from 'msw';
http.get('/api/report', async () => {
await delay(3000);
return HttpResponse.json({ ready: true });
});A fake clock can stop delay() from resolving. Advance the timer or restore real timers in that test.
Use a response once one-time-response
server.use(
http.get('/api/job', () => HttpResponse.json({ state: 'pending' }), { once: true }),
http.get('/api/job', () => HttpResponse.json({ state: 'done' }))
);The once handler expires after its first match. resetHandlers() restores handlers supplied to setupServer.
Match a named GraphQL query graphql-operation
const api = graphql.link('https://api.example.com/graphql');
api.query('GetUser', ({ variables }) =>
HttpResponse.json({ data: { user: { id: variables.id } } })
);Operation-name matching requires the client query to be named. Anonymous operations cannot match GetUser.
Reply when a socket connects websocket-connection
import { ws } from 'msw';
const chat = ws.link('wss://chat.example.com');
chat.addEventListener('connection', ({ client }) => {
client.send(JSON.stringify({ type: 'ready' }));
});Close the client during cleanup or the test runner may retain an open handle.
Call the real service once bypass-interception
http.get('/api/profile', async ({ request }) => {
const response = await fetch(bypass(request));
return HttpResponse.json({ ...(await response.json()), preview: true });
});bypass() marks the nested request so this handler does not catch its own fetch recursively.
Inspect a request event observe-request
const listener = ({ request }) => seen.push(request.clone());
server.events.on('request:start', listener);
await callApi();
server.events.removeListener('request:start', listener);Clone before reading a body twice. Remove the listener or later tests will keep receiving events.
Scope handlers to one request async-boundary
app.get('/checkout', server.boundary(async (req, res) => {
server.use(http.get('https://pay.example/session/:id', ({ params }) =>
HttpResponse.json({ id: params.id, state: 'open' })));
res.json(await createCheckout(req));
}));server.boundary() isolates runtime handlers through that async context, which prevents concurrent requests from sharing overrides.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| nock | npm | Use it for Node-only HTTP interception and optional request recording. |
| fetch-mock | npm | Use it when replacing fetch directly is enough and no browser Service Worker is needed. |
| miragejs | npm | Use it when the mock needs models, factories, and persistent relationships. |
| @pact-foundation/pact | npm | Use it when a consumer's examples must be checked against a provider contract. |
More testing guides
pytest · chai · vitest · jsdom · playwright · coverage · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

