MSW v2 vs Nock for Node HTTP mocking
Choosing between MSW v2 and Nock for Node HTTP mocking comes down to where each library intercepts traffic and how that choice ages as your stack moves to ESM and native fetch. This guide is for backend and full-stack engineers running Vitest or Jest who need to stub outbound HTTP from service code, and it covers the interception model, ESM compatibility, fetch versus http support, request assertions, and a concrete migration path. Both tools sit at the integration tier of external service simulation; the question is which interception strategy matches your runtime.
Root Cause Analysis
The two libraries solve the same problem at different layers, and that single design difference drives almost every practical trade-off. Nock monkey-patches Node’s http and https modules, registering scopes that match outbound requests at the module level. That works flawlessly for any client built on those modules — older axios, request, node-fetch@2 — because they all funnel through http.ClientRequest. The friction appears with the global fetch shipped in Node 18+, which is implemented on undici and does not route through the classic http module, so Nock’s classic interceptor never sees it without extra adapters.
MSW v2 intercepts higher up, at the request layer, using a unified interceptor that hooks both the classic http path and undici/global fetch. The same handler definitions you write for Node tests also run in the browser via a Service Worker, so the mock contract is portable across Playwright component testing and Node suites. The cost is a slightly different mental model — you describe a request-handler surface rather than a sequence of scoped expectations.
The interception-layer difference also dictates how each library treats order and consumption. Nock scopes are consumed: by default a .reply() matches exactly one request, and a second identical call falls through unless you add .persist() or .times(n). That makes Nock naturally good at asserting “this endpoint was called exactly once with this body,” because an extra call simply has no scope to satisfy it and surfaces as an error. MSW handlers are persistent by default — they answer every matching request until you reset them — which suits a declarative “this is what the world looks like” model but means call-count assertions are something you add yourself with a spy rather than something the framework enforces. Neither is better in the abstract; they encode different default assumptions about what a test is trying to prove.
There is also a maintenance dimension that outlives any single suite. Because Nock patches Node-internal modules, it is sensitive to how those internals evolve and to whichever HTTP client a dependency pulls in transitively. MSW’s request-layer model is insulated from that churn, which is why teams standardizing one mock definition across unit, integration, and browser tiers tend to converge on it — the same handlers that back a Vitest suite can back a Storybook interaction test or a local dev mock without rewriting anything.
Reproducible Setup
Install whichever you are evaluating as a dev dependency:
npm install msw --save-dev # request-layer interception, fetch + http
npm install nock --save-dev # http/https module interception
A minimal MSW v2 Node setup binds handlers to the runner lifecycle:
// msw.setup.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/users/:id', ({ params }) =>
HttpResponse.json({ id: params.id, name: 'Ada' }),
),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
The equivalent Nock setup registers a scope per expectation and asserts it was consumed:
// nock.test.ts
import nock from 'nock';
import { afterEach, expect, it } from 'vitest';
afterEach(() => nock.cleanAll());
it('mocks a user request', async () => {
const scope = nock('https://api.example.com').get('/users/7').reply(200, { id: '7', name: 'Ada' });
// ...call the code under test...
expect(scope.isDone()).toBe(true); // fails if the request never fired
});
Implementation
The decisive factors differ by project. Use the table to map your constraints to a choice.
| Factor | MSW v2 | Nock |
|---|---|---|
| Interception layer | Request layer (unified http + undici/fetch) |
http/https module monkey-patch |
Native fetch (Node 18+) |
Intercepted out of the box | Needs adapter/workaround; classic scopes miss it |
| ESM support | First-class; ships ESM build | Works, but global patching can clash with mocked ESM |
| Browser reuse | Same handlers run via Service Worker | Node only |
| Request assertions | Inspect request inside the handler; assert with your own spy |
scope.isDone(), strict path/body matchers |
| Default unmatched behavior | onUnhandledRequest: 'error' fails loudly |
nock.disableNetConnect() to block escapes |
| Mental model | Declarative handler surface | Sequential, scoped expectations |
| Best fit | fetch-based code, shared browser/Node mocks |
Legacy http/axios clients, strict per-call expectations |
Request assertions are where the two philosophies diverge most. Nock bakes expectation into the scope — a request that does not match the registered path, query, or body simply does not get mocked, and scope.isDone() reports whether the expected call fired. MSW treats matching and assertion as separate concerns: the handler decides what to return, and you assert on the captured request with a normal spy.
// MSW v2: assert on the captured request explicitly
import { http, HttpResponse } from 'msw';
import { vi } from 'vitest';
import { server } from './msw.setup';
const seen = vi.fn();
server.use(
http.post('https://api.example.com/charges', async ({ request }) => {
seen(await request.json());
return HttpResponse.json({ id: 'ch_1' }, { status: 201 });
}),
);
// after exercising the code:
// expect(seen).toHaveBeenCalledWith({ amount: 500 });
For codebases mixing fetch and axios, MSW’s single interceptor covers both transports uniformly, which removes a class of “works for axios, misses fetch” surprises — the same problem teams hit when stubbing fetch and axios manually.
Sequenced and conditional responses expose the other half of the philosophy gap. Nock encodes a sequence by registering scopes in order — the first matching call consumes the first scope — which makes “first call fails, retry succeeds” idiomatic:
import nock from 'nock';
nock('https://api.example.com')
.get('/health')
.reply(503) // first call: service unavailable
.get('/health')
.reply(200, { ok: true }); // retry: healthy
MSW expresses the same idea inside one handler by closing over a counter, which keeps the logic in JavaScript rather than in scope ordering:
import { http, HttpResponse } from 'msw';
import { server } from './msw.setup';
let calls = 0;
server.use(
http.get('https://api.example.com/health', () => {
calls += 1;
return calls === 1
? new HttpResponse(null, { status: 503 })
: HttpResponse.json({ ok: true });
}),
);
Query and body matching is strict in Nock and explicit in MSW. Nock will only satisfy a scope when the query string and body match what you registered, so a mismatch leaves isDone() false. MSW gives you the parsed request and you decide what matters:
http.get('https://api.example.com/search', ({ request }) => {
const term = new URL(request.url).searchParams.get('q');
if (!term) return new HttpResponse(null, { status: 400 });
return HttpResponse.json({ results: [`hit for ${term}`] });
});
This is the practical reason teams pick one or the other: if you want the framework to enforce that a call matched an exact contract, Nock’s consumption model does it for free; if you want to branch on request content and keep matching logic readable, MSW’s handler body is clearer.
Verification
Both libraries can guarantee no request escapes to the live network, which is the property you actually want to verify. With MSW, onUnhandledRequest: 'error' makes any uncovered request fail the test. With Nock, call nock.disableNetConnect() in setup so an unmocked request throws instead of hitting the wire:
import nock from 'nock';
import { beforeAll, afterAll } from 'vitest';
beforeAll(() => nock.disableNetConnect());
afterAll(() => nock.enableNetConnect());
A clean run under either guard confirms full interception. The difference shows up when something is missed: Nock throws a NetConnectNotAllowedError naming the host and path, while MSW reports the unhandled method and URL — both pinpoint the gap quickly.
If you decide to migrate from Nock to MSW v2, do it incrementally rather than in one sweep. The interception models collide if both are active for the same request, so the safe order is per file:
- Inventory the scopes in the target file — note each host, path, query, body matcher, status, and any
.times()/.persist()ordering you are relying on. - Translate each scope to a handler. A
nock('host').get('/path').reply(200, body)becomeshttp.get('host/path', () => HttpResponse.json(body)). Sequenced replies become a counter inside one handler, as shown above. - Move call-count assertions to spies. Anywhere you asserted
scope.isDone(), capture the request in the handler withvi.fn()and assert on the spy, since MSW handlers are persistent and do not self-consume. - Delete the Nock guards last. Remove
nock.cleanAll()anddisableNetConnect()from the file only after every scope is gone, and confirmserver.listen({ onUnhandledRequest: 'error' })now owns the escape check for that file. - Run the file in isolation before merging, so a lingering global Nock patch from another file cannot mask a missing handler.
Keep the two libraries in separate test files until the last Nock scope in the project is gone; only then remove the Nock dependency entirely.
Troubleshooting
- Nock ignores my
fetchcalls. Node’s globalfetchruns onundici, which bypasses Nock’s classichttppatch. Either switch the suite to MSW v2 or route the client through thehttpmodule; do not assume a registered scope coversfetch. - MSW handler never matches. The URL or method differs from the request — MSW matches the full method and path. Add
onUnhandledRequest: 'error'so the mismatch surfaces with the exact URL instead of silently passing through. - Migrating leaves both active. Running Nock’s global patch and MSW’s interceptor in the same process can double-intercept. Migrate file by file and remove
nock.cleanAll()/disableNetConnect()from a file once its scopes are gone. - A Nock scope stays
isDone() === false. The request did fire but did not match — usually a query-string or body mismatch, because Nock matches both by default. Log the outbound request, compare it field by field against the scope, and use.query(true)or a body-matching function if the payload is dynamic. - MSW passes through to the network in CI but not locally. You forgot
onUnhandledRequest: 'error'in one environment, so an unmocked request silently hit the wire where DNS or a proxy made it look like a pass. Set the strict option unconditionally and let the suite fail on the gap rather than depending on network reachability. axiosbaseURL hides the real host from the matcher. Both libraries match the fully resolved URL. Ifaxiosis configured with abaseURL, register the handler or scope against the absolute URL the request resolves to, not the relative path you pass toaxios.get.- Timers or retries fire extra requests. A client with built-in retry can issue more calls than you expect, consuming additional Nock scopes or tripping a strict MSW count assertion. Mock or advance fake timers and assert the exact retry count you intend, rather than assuming a single call.
FAQ
Should I migrate an existing Nock suite to MSW v2?
Migrate if your code is moving to native fetch or you want one mock definition shared between Node and browser tests; that portability is MSW’s main payoff. If your suite is entirely http/axios-based and relies on strict per-call scope.isDone() expectations, Nock is stable and there is little to gain from churning it.
Can I run MSW v2 and Nock side by side during a migration?
Yes, but scope them to different test files rather than the same process, because both intercept globally and can collide. Convert one file at a time, deleting the Nock scopes and their cleanAll/disableNetConnect calls as each file flips to MSW handlers.
How do request body assertions compare?
Nock matches the body as part of the scope, so a mismatched body means the request is simply unmocked and scope.isDone() stays false. MSW separates the two: the handler returns a response and you assert on the parsed request body with a spy, which gives clearer failure messages when only the payload is wrong.
Which one is faster in a large CI suite?
The interception overhead of both is negligible next to test setup and teardown, so raw speed rarely decides it. The real CI cost is flakiness from escaped requests and duplicated mock maintenance; MSW’s single definition shared across tiers usually reduces total upkeep, while Nock’s per-call consumption can make individual tests marginally cheaper to reason about. Measure your own suite rather than choosing on assumed performance.
Does either work with native fetch in Jest?
MSW v2 works with native fetch under Jest once fetch and the related web globals are present in the test environment; modern Jest with a Node 18+ runtime exposes them. Nock still misses native fetch because it patches the classic http module regardless of runner, so under Jest you would either polyfill the client onto http or switch the fetch-based suite to MSW.
Related
- Back to External Service Simulation
- Using MSW to mock GraphQL endpoints locally — the GraphQL counterpart to this REST comparison.
- Mocking fetch and axios in Vitest without memory leaks — manual transport stubbing trade-offs.
- HTTP Request Stubbing Techniques — the broader stubbing patterns these libraries implement.