Root cause #
MSW resolves a request by walking its handler list and using the first one that matches. Runtime handlers added with server.use() are prepended, so the most recently added override wins — which is what makes per-test scenarios work. Initial handlers passed to setupServer or setupWorker are consulted afterwards in the order they appear in the array, so the first declaration wins among them.
That combination produces the two classic surprises. The first is shadowing: a permissive pattern early in the initial array — http.get('/api/*'), or a route with an optional segment that also matches a longer path — answers requests intended for a specific handler declared later. Nothing warns about it, because from the library’s point of view a handler matched and returned a response.
The second is override lifetime. server.use() handlers persist until resetHandlers() is called, so an override added in one test still answers in the next unless the reset is wired into afterEach. That produces a failure whose cause is in a different file, and whose symptom moves when the test order changes — the signature of the leakage discussed in Test Isolation & State Leakage.
A third, quieter cause is matching semantics rather than order. Path parameters, optional segments and wildcards match more than people expect: /api/invoices/:id matches /api/invoices/summary, so a specific summary route declared after it never runs. The fix there is ordering, but the diagnosis is easier once you know matching is greedy in this direction.
Step-by-step fix #
1. Print the resolution for the failing request #
Stop guessing. MSW’s lifecycle events tell you which handler answered, and that single line usually ends the investigation.
// Trade-off: lifecycle logging is noisy on a full run; enable it behind an
// environment flag and use it while debugging a specific failure.
if (process.env.MSW_DEBUG) {
server.events.on('request:match', ({ request }) =>
console.log('matched ', request.method, request.url));
server.events.on('request:unhandled', ({ request }) =>
console.log('unhandled', request.method, request.url));
}
request:match firing for a request you expected a different handler to serve is proof of shadowing; request:unhandled means the pattern does not match at all, which is a different bug with a different fix.
2. Order specific patterns before broad ones #
Within the initial handler array, declare the narrowest routes first and keep wildcards at the end.
// mocks/handlers.js
// Trade-off: maintaining an order-sensitive list is a small ongoing cost, and
// the alternative — broad patterns first — silently swallows specific routes.
export const handlers = [
http.get('/api/invoices/summary', () => HttpResponse.json({ total: 1250 })), // specific
http.get('/api/invoices/:id', ({ params }) => HttpResponse.json({ id: params.id })),
http.get('/api/*', () => HttpResponse.json({ error: 'not_implemented' }, { status: 501 })), // catch-all last
];
The trailing catch-all is a useful pattern in its own right: it converts an uncovered path into a clear 501 rather than an unhandled-request error, which is sometimes easier to read in a large suite — though it does trade away the strictness discussed in Handling Unhandled Requests and Passthrough in MSW.
3. Reset overrides between tests, without exception #
resetHandlers() returns the list to the initial set. Wire it in setup, not per file, so no spec can forget.
// Trade-off: resetting between tests means an override must be declared in the
// test that uses it — slightly more verbose, and the only way to stay isolated.
afterEach(() => server.resetHandlers());
// To change the baseline for one file only, pass the new defaults to the reset:
beforeEach(() => server.resetHandlers(...adminHandlers));
4. Scope overrides to one call, when that is what you mean #
An override that should apply to a single request can say so, which removes an entire class of leakage even before the reset runs.
// Trade-off: a one-shot override is precise and makes a retrying client fall
// through to the default on its second attempt — which is often exactly right.
server.use(
http.get('/api/invoices', () => HttpResponse.json({ error: 'boom' }, { status: 500 }), { once: true })
);
Sequencing several once handlers is how you express “fail twice, then succeed” without any module-level counter.
5. Check the URL you are matching, not the one you think you are #
Relative patterns resolve against the current origin, which differs between a component test in a DOM environment and a browser test on the application’s origin. A handler written as /api/invoices will not match https://api.example.com/invoices.
// Trade-off: absolute patterns are unambiguous and duplicate the host in every
// handler; a base-URL constant keeps them consistent without hard-coding.
const API = process.env.API_ORIGIN ?? ''; // '' → same-origin in the browser
http.get(`${API}/api/invoices`, () => HttpResponse.json(invoices));
6. Verify precedence with a test, not by reasoning #
When the order matters, encode it. A short test that asserts the specific route wins over the general one documents the intent and fails if someone reorders the array.
// Trade-off: a test about the mock layer rather than the product, worth having
// only where a real shadowing bug has already cost someone an afternoon.
test('summary route is not shadowed by the id route', async () => {
const res = await fetch('/api/invoices/summary');
await expect(res.json()).resolves.toHaveProperty('total'); // not { id: 'summary' }
});
Pitfalls #
- A wildcard declared early. It answers everything below it. Mitigation: narrow patterns first, catch-alls last.
- Assuming a path parameter will not match a literal.
/:idmatchessummary. Mitigation: declare literal routes before parameterised ones. - Forgetting
resetHandlers(). An override answers in later tests. Mitigation: put it inafterEachin the shared setup. - Debugging by reading the handler list. Slow and often wrong. Mitigation: log
request:matchand read which handler actually answered. - Relative patterns against a cross-origin API. Nothing matches and the request is reported unhandled. Mitigation: build patterns from a base-URL constant.
- Expressing sequences with module-scope counters. They leak into the next test. Mitigation: use
once: truehandlers, or keep the counter inside the test.
Reliability targets #
| Metric | Target | Notes |
|---|---|---|
| Wildcard handlers declared before specific ones | 0 | Reviewed when handlers are added |
| Tests leaking overrides | 0 | resetHandlers() in shared setup |
| Time to identify the answering handler | < 1 min | With lifecycle logging enabled |
| Module-scope counters in handlers | 0 | Replaced by once: true sequences |
| Precedence tests for known-ambiguous routes | 1 per ambiguity | Encodes the intent |
Frequently Asked Questions #
Q: My server.use() override is ignored. Why?
A: Either it was registered before another use() for the same route — the most recent wins, so a later override shadows it — or it was reset before the request happened, which occurs when the override is set in a beforeAll and the reset runs in afterEach. Register scenario overrides inside the test that needs them.
Q: Does handler order matter for overrides too?
A: Yes, in the opposite direction. Overrides are prepended, so among several use() calls the last one registered is checked first, whereas among the initial handlers the first declared is checked first. Keeping scenario overrides to one per test avoids ever having to think about it.
Q: How do I express “fail twice, then succeed”?
A: Register two once: true failing handlers followed by the normal default, or one once handler and let the baseline serve the retry. Both keep the sequence’s state inside MSW rather than in a variable that can outlive the test.
Q: Can two handlers ever both run for one request? A: No. Resolution stops at the first match, so a handler cannot “fall through” to another one after returning a response. If you need conditional behaviour — answer differently based on a header or a query value — put the condition inside a single handler and branch there, rather than declaring two handlers and hoping the right one wins.
Q: Is a catch-all handler a good idea?
A: It depends on which failure you would rather have. A trailing catch-all turns an uncovered path into a predictable 501, which reads clearly in a large suite. It also disables the unhandled-request policy for everything it matches, so genuine gaps stop being reported. Use it for a bounded prefix you own, never for *.
Q: Why does my handler match in unit tests but not in the browser?
A: Usually the origin. A relative pattern resolves against whatever origin the runtime is on — localhost in a DOM environment, the application’s host in a browser test — so a cross-origin API needs an absolute pattern. Build patterns from a shared base-URL constant so both runtimes produce the same match, in line with Sharing MSW Handlers Between Jest and Playwright.