Article · Network & API Mocking for Reliable Tests

Debugging MSW Handler Precedence

The response is wrong, the handler looks right, and the test fails in a way that suggests the mock is broken. Nine times in ten the mock is fine — a different handler matched first. Precedence in MSW follows two simple rules that interact in ways people find surprising, and this guide makes them explicit, as a companion to the setup guidance in MSW in JavaScript Test Suites.

12 sections URL: /network-api-mocking-for-reliable-tests/msw-in-javascript-test-suites/debugging-msw-handler-precedence/
Resolution order for an incoming request Runtime overrides are checked first in reverse order of registration, then the initial handlers in array order; the first match wins. server.use() — most recent first earlier server.use() calls, in reverse order initial handlers — array order, first to last no match → onUnhandledRequest policy the first handler that matches answers — a broad pattern high in the list shadows everything below it
Two orderings stacked: overrides newest-first, initial handlers first-declared-first. Confusion nearly always comes from mixing them up.

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.

Shadowing by a greedy path parameter A route with a path parameter matches a more specific path declared after it, so the specific handler never runs. GET /api/invoices/summaryincoming request handler 1: /api/invoices/:id — matches, id = "summary"answers with the wrong shape handler 2: /api/invoices/summarynever reached — declared after swapping the two declarations fixes it; nothing else needs to change
A path parameter will happily match a literal segment; specificity has to come from ordering, not from the pattern.

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. /:id matches summary. Mitigation: declare literal routes before parameterised ones.
  • Forgetting resetHandlers(). An override answers in later tests. Mitigation: put it in afterEach in the shared setup.
  • Debugging by reading the handler list. Slow and often wrong. Mitigation: log request:match and 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: true handlers, or keep the counter inside the test.
Two symptoms, two different causes A wrong response means shadowing; an unhandled request means the pattern does not match at all. wrong response body request:match fired → another handler shadowed yours unhandled request error request:unhandled fired → the pattern does not match at all
One log line separates an ordering problem from a matching problem, and they have completely different fixes.

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
Precedence scorecard Targets for wildcard ordering, override leakage, diagnosis time and handler state. 0early wildcards 0leaked overrides < 1 minto diagnose 0module-scope state
Most of this collapses into one habit: log the match before reasoning about the list.

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.