Article · Network & API Mocking for Reliable Tests

Handling Unhandled Requests and Passthrough in MSW

A mocked suite that quietly reaches the real internet is worse than one that does not mock at all, because it looks deterministic while depending on someone else's uptime. The setting that decides which of those you have is onUnhandledRequest, and its default is the permissive one. This guide takes the strictness question raised in MSW in JavaScript Test Suites and works through what to block, what to allow deliberately, and how to migrate an existing suite to strict without a wall of failures.

12 sections URL: /network-api-mocking-for-reliable-tests/msw-in-javascript-test-suites/handling-unhandled-requests-and-passthrough-in-msw/
What happens to a request with no matching handler Bypass sends it to the real network, warn logs and sends it anyway, and error fails the test at the point of the gap. unmatched requestno handler covers it 'bypass'silent live call 'warn' (default)logged, still sent 'error'test fails at the gap only the right-hand option makes "everything is mocked" a property rather than a belief
Warn is the default and the worst of both worlds in CI: the log scrolls past and the request still goes out.

Root cause #

Interception libraries have to decide what to do with traffic nobody described. Blocking by default would break development workflows — assets, source maps, hot-reload sockets and telemetry all flow through the same layer — so the default is permissive, and permissive means the request completes against the real origin. In a local run that is invisible and harmless. In CI it produces a suite whose results depend on external services, DNS, and the network policy of the runner.

The failure mode this creates is delayed and confusing. Tests pass for weeks because the real endpoint happens to answer with something the assertions tolerate. Then a vendor has an outage, or the runner loses egress, or the endpoint starts returning a slightly different shape, and a set of apparently unrelated tests fails at once. Nobody suspects the mocking layer, because the mocking layer was believed to be complete.

There is a second, subtler cost. Every unhandled request is a piece of the application’s behaviour that no test is actually exercising deliberately. The handler set is the written-down understanding of what the application talks to; gaps in it are gaps in that understanding, and running strict converts them from unknown unknowns into a list.

Step-by-step fix #

1. Turn on strict mode with a deliberate allowlist #

Use the callback form rather than the string, so exceptions are explicit and documented in code.

// vitest.setup.js
// Trade-off: an allowlist needs maintaining as the app grows; the alternative
// is a blanket policy that also hides the requests you care about.
const ALLOWED = [
  /\/@vite\//,          // dev server internals
  /\.map$/,             // source maps
];

server.listen({
  onUnhandledRequest(request, print) {
    if (ALLOWED.some((re) => re.test(request.url))) return;   // deliberate
    print.error();      // everything else fails the run
  },
});

The allowlist should be short and each entry should be obvious. A growing allowlist is a signal that the handler set is falling behind the application, not that the policy is too strict.

2. Migrate by counting first, blocking second #

Switching an established suite straight to 'error' produces a wall of failures that nobody can triage. Run in counting mode for a few days to size the problem and prioritise.

// Trade-off: counting delays the guarantee by a few days and makes the
// migration reviewable instead of a single overwhelming red build.
const unhandled = new Map();

server.listen({
  onUnhandledRequest(request) {
    const key = `${request.method} ${new URL(request.url).pathname}`;
    unhandled.set(key, (unhandled.get(key) ?? 0) + 1);
  },
});

afterAll(() => {
  const rows = [...unhandled.entries()].sort((a, b) => b[1] - a[1]);
  for (const [route, count] of rows) console.log(`${String(count).padStart(5)}  ${route}`);
});

The output is a ranked work list: the top few paths usually account for most of the escapes, and covering them takes the count down fast.

Migration path from permissive to strict Count escapes, cover the most frequent paths, allowlist the deliberate ones, then switch to error. 1. countrank by frequency 2. covertop paths first 3. allowlistthe deliberate few 4. errorguarantee the count in step 1 is usually dominated by three or four paths — the migration is smaller than it looks
Counting before blocking turns an intimidating switch into a short, ordered task list.

3. Use explicit passthrough where a real call is intended #

Occasionally a request genuinely should reach a real service — a local test server, a container the pipeline started. Say so in a handler rather than by weakening the global policy.

// Trade-off: explicit passthrough documents the intent at the route level,
// which is much easier to audit than a permissive global setting.
import { http, passthrough } from 'msw';

export const handlers = [
  http.all('http://localhost:4000/*', () => passthrough()),   // our own test server
  // …everything else is mocked
];

Passthrough at the handler level also survives review: a reader can see which host is allowed to be real, whereas onUnhandledRequest: 'bypass' says only “anything, anywhere”.

4. Keep the policy identical in every runtime #

A suite that is strict in Node and permissive in the browser has a gap exactly where browser tests run, which is where escapes are most likely. Set the same policy in both entry points, as part of the shared setup described in Sharing MSW Handlers Between Jest and Playwright.

// mocks/policy.js — one policy object, imported by both runtimes
export const policy = {
  onUnhandledRequest(request, print) {
    if (/\/(telemetry|__vite)/.test(request.url)) return;
    print.error();
  },
};

5. Distinguish “not mocked” from “mocked to fail” #

A strict policy makes an uncovered path fail, and the failure message must not be confused with a deliberate error case. Name deliberate failures in the handler so a reader can tell them apart at a glance.

// Trade-off: naming conventions in fixtures are a small ceremony that saves
// real time when a failure message is the only evidence available.
http.get('/api/invoices', () =>
  HttpResponse.json({ error: 'deliberate_test_failure' }, { status: 500 })
);

6. Assert the guarantee in CI #

Make the absence of escapes a checked property rather than a configuration you hope is still in place.

// Trade-off: a meta-test rather than a product test, and it is what catches
// someone relaxing the policy to get an unrelated build green.
test('unhandled requests are fatal', async () => {
  await expect(fetch('https://unmocked.example.com/thing'))
    .rejects.toThrow();
});

Pitfalls #

  • Leaving the default 'warn' in CI. The warning scrolls past and the request still goes out. Mitigation: 'error' with an allowlist.
  • A blanket 'bypass' to silence noise. Every gap becomes invisible. Mitigation: handler-level passthrough() for the specific host instead.
  • Switching to strict without counting first. A wall of failures nobody can triage, followed by a rollback. Mitigation: count, rank, cover, then block.
  • A growing allowlist. It becomes a second, undocumented bypass. Mitigation: review its size; growth means the handler set is behind the application.
  • Different policies per runtime. The browser level keeps escaping. Mitigation: one shared policy module.
  • Confusing an uncovered path with a deliberate 500. Triage goes down the wrong route. Mitigation: mark deliberate failures in the response body.
Two ways to allow a real request Global bypass allows everything unmatched; handler-level passthrough allows one named host. global bypass any unmatched request, any host invisible in review, unbounded handler passthrough one named host, stated in code auditable, bounded
Both allow a real call; only one leaves a record of which call was allowed and why.

Reliability targets #

Metric Target Notes
Unhandled requests in CI 0 Enforced by the 'error' policy
Allowlist entries ≤ 5 Each with a comment explaining why
Hosts with handler-level passthrough ≤ 1 Normally just the local test server
Policy parity across runtimes 100% One shared policy module
Test failures caused by external outages 0 per quarter The outcome the policy protects
Strictness scorecard Targets for unhandled requests, allowlist size, passthrough hosts and outage-caused failures. 0unhandled requests ≤ 5allowlist entries ≤ 1passthrough host 0outage failures
A short allowlist and a single passthrough host keep the guarantee legible to anyone reviewing it.

Frequently Asked Questions #

Q: Strict mode fails on requests my application does not make. Where are they from? A: Usually the environment rather than the application: dev-server internals, source-map fetches, favicon requests, or telemetry from a third-party script. Allowlist those explicitly with a comment. If the list grows past a handful, check whether a dependency is calling out unexpectedly — which is worth knowing about on its own.

Q: The same request is unhandled in the browser but handled in Node. How? A: The two runtimes see different origins and sometimes different request sets. A relative pattern resolves against the current origin, so a cross-origin API needs an absolute one; and the browser makes requests Node never does — favicons, fonts, service-worker updates — which the Node policy never had to classify. Align the patterns first, then extend the allowlist for the browser-only traffic.

Q: Is 'warn' a reasonable compromise? A: For local development, yes. For CI, no: the warning goes into a log nobody reads and the request still leaves the machine, so you get the noise without the protection. Use 'warn' locally and 'error' in CI, driven by the CI environment variable.

Q: What if a test genuinely needs to hit a real service? A: Allow it at the handler level with passthrough() for that specific host, and keep the number of such tests very small. A test that depends on a real external service is not a unit or component test any more; it is a contract test, and it belongs on a schedule rather than on the merge path — the same reasoning as in Detecting Stale Recorded Fixtures in CI.

Q: How do I keep the allowlist from quietly becoming a bypass? A: Treat each entry as a small piece of technical debt with a reason attached. Require a comment naming why the pattern is allowed, review the list whenever it grows, and assert its length in a test if it has a history of expanding. An allowlist of three well-understood patterns is a reasonable engineering decision; one of thirty is a permissive policy written in a more elaborate way.

Q: Does strict mode slow anything down? A: No — it removes work rather than adding it, because requests that used to travel to a real origin now fail immediately. The main effect people notice is that the suite gets faster once the escapes are covered, since the slowest requests in a mocked suite are invariably the ones that were not mocked.