Root cause #
MSW layers handlers in two tiers: the base handlers passed to setupServer()/setupWorker(), and runtime handlers added mid-test with server.use(). The runtime tier is the trap. server.use() pushes an override onto a stack that persists until you explicitly reset it. If test A calls server.use(http.get('/cart', errorResolver)) and you never reset, test B inherits that error resolver. Test B may not even hit /cart, so it passes — until a refactor makes it touch the endpoint, and now it fails for a reason that has nothing to do with the change under test.
In the async world this is worse than it looks. A service worker (setupWorker) intercepts at the network boundary of the page, so a leaked handler outlives not just the test but potentially the whole page session. Without close(), the worker keeps intercepting between files, and a request you expected to reach a real backend (or a Playwright route) gets quietly stubbed instead. The fix is a strict, always-run lifecycle.
Step-by-step fix #
1. Start the server once, before all tests #
// tests/setup.js
const { setupServer } = require('msw/node');
const { handlers } = require('./handlers');
const server = setupServer(...handlers);
// onUnhandledRequest: 'error' surfaces escaped requests instead of silently passing them.
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
module.exports = { server };
2. Reset runtime handlers after every test #
This is the line that prevents bleed. It drops everything added by server.use() and restores the base set.
// tests/setup.js (continued)
afterEach(() => {
// Drops runtime overrides from server.use() so test B never inherits test A's mocks.
server.resetHandlers();
});
3. Close the server when the file finishes #
afterAll(() => {
// Stops the interceptor entirely; without this the worker can leak across files in watch mode.
server.close();
});
4. Tear down the browser worker the same way #
For setupWorker() running in the page, stop it explicitly so it does not survive the page session.
// in the app/test boot for browser MSW
const worker = setupWorker(...handlers);
await worker.start({ onUnhandledRequest: 'error' });
// On teardown — stops interception so a later test reaches the real route, not a stale mock.
afterEach(() => worker.resetHandlers());
afterAll(() => worker.stop());
When you seed per-environment fixtures into these handlers, keep the data deterministic — see Seeding Deterministic Mock Data Across Environments.
Pitfalls #
- Calling
server.use()without a matchingresetHandlers()— overrides stack up. Mitigation: putresetHandlers()in a globalafterEach, not per file. - Skipping
close()in watch mode — the worker survives reloads. Mitigation: always pairlisten()withafterAll(close). - Using
onUnhandledRequest: 'bypass'— escaped requests pass silently and hide gaps. Mitigation: use'error'in tests so unmocked calls fail loudly. - Re-creating
setupServerper test — slow and loses the base handlers. Mitigation: create once at module scope, reset between tests. - Forgetting the browser worker has its own lifecycle. Mitigation: mirror
resetHandlers/stopforsetupWorkerexactly as for the node server.
Reliability targets #
| Target | Goal |
|---|---|
| Cross-test mock bleed incidents | 0 per suite run |
| Unhandled-request escapes | 0 (enforced via onUnhandledRequest: 'error') |
| Handler reset coverage | 100% of tests |
| Order-independent pass rate | ≥ 99.5% |
Frequently Asked Questions #
Q: Do I need both resetHandlers() and close()?
A: Yes. resetHandlers() runs between tests to drop runtime overrides; close() runs once at the end to stop the interceptor entirely. Skipping either leaves a different category of leak.
Q: Where should the lifecycle hooks live?
A: In a shared global setup file referenced by your test runner config, so every spec inherits the same listen/resetHandlers/close cycle without copy-pasting.
Q: Why use onUnhandledRequest: 'error'?
A: It turns any request your handlers do not cover into an immediate failure, exposing missing mocks instead of letting them hit a real backend and produce nondeterministic results.
Deciding Whether the Worker Is Needed at All #
Before tuning teardown, it is worth asking whether a service worker is the right mocking layer for a given suite, because much of the complexity here exists only because of the worker’s lifecycle.
The worker earns its place when the same handler set must serve browser tests, component tests and development mode, so that one description of the API keeps every level honest. That is a genuine benefit and it is why teams adopt it.
It is unnecessary when the only consumer is a browser suite that already has route interception available. Route handlers register synchronously, have no activation lifecycle, and disappear with the page — which removes the entire class of first-request escapes and teardown ordering issues in exchange for handlers that live in the test rather than in a shared module.
The pragmatic arrangement in many suites is both: the shared handler set through the worker for the realistic baseline, and route interception for scenario behaviour that a spec should state locally. The teardown discipline then applies to the worker only, and the scenario layer needs nothing beyond the runner’s own per-test isolation.
Where a suite is fighting worker lifecycle problems and does not need cross-level handler sharing, removing the worker is a legitimate simplification rather than a retreat.
The Two Lifecycles That Have to Line Up #
A service-worker-based mock has two independent lifecycles, and nearly every teardown problem is a mismatch between them.
The worker lifecycle is browser-managed and asynchronous: registration, installation, activation, and eventually unregistration. None of these is instantaneous, and a navigation that begins before activation completes is served without the worker — the classic “the mock did not apply on the first request” flake.
The handler lifecycle is test-managed and synchronous: handlers are registered, overridden per test, and reset. Resetting handlers does nothing to the worker itself, which is why a test can reset correctly and still see stale behaviour if the worker from a previous context is still controlling the page.
Getting them to line up requires two habits. Await activation before the application makes its first request, rather than starting the worker and rendering immediately. And reset handlers in a shared teardown so no spec can forget, while unregistering the worker only when the context genuinely changes — unregistering per test is slow and usually unnecessary, since a fresh browser context brings a fresh registration anyway.
// Await activation; reset handlers every test; unregister only when needed.
// Trade-off: awaiting start adds a few hundred milliseconds to bootstrap and
// removes an intermittent first-request escape that is very hard to diagnose.
await worker.start({ onUnhandledRequest: 'error', waitUntilReady: true });
afterEach(() => worker.resetHandlers());
Caches Outlive Workers #
Unregistering a worker does not empty the caches it created, and an offline-capable application will keep answering from Cache Storage after the worker that populated it is gone. That produces a distinctive failure: a test sees data it never mocked, from a request that never reached the network, in a run where the mocking layer appears correctly torn down.
Three surfaces need attention when a suite exercises offline behaviour. Cache Storage entries, deleted by name. IndexedDB databases, which survive a storage clear untouched. And any registration left over from a previous context, which can control a page before the new worker activates.
The ordering matters as much as the operations. Unregistration is asynchronous, so a navigation started immediately afterwards may still be controlled by the outgoing worker; awaiting the unregistration and the cache deletions before navigating is what makes the teardown deterministic rather than usually-correct.
Where the application is not offline-capable, most of this is unnecessary and the simpler arrangement — a fresh context per test plus a handler reset — covers everything. It is worth knowing which situation you are in, because applying the full teardown to a suite that does not need it adds seconds per test for no benefit.
Prove the Teardown Once #
A single spec that asserts an unmocked request fails is worth more than any amount of teardown code nobody verifies. It fails immediately when the strictness setting is relaxed or the worker stops applying.
Where a suite does not exercise offline behaviour, most of this teardown is unnecessary, and applying it anyway costs seconds per test. Knowing which situation you are in is worth the five minutes it takes to check.