Article · Network & API Mocking for Reliable Tests

Tearing Down MSW Service Workers Between Tests

Mock Service Worker leaks are one of the quietest causes of flaky suites: a handler registered in one test stays live and silently answers requests in the next, so a spec passes for the wrong reason and fails the moment isolation breaks. This guide covers the exact teardown lifecycle — server.resetHandlers() in afterEach, server.close() at the end, and the worker equivalents — so mocks never bleed across tests. It builds on the broader Playwright Route Mocking Strategies and pairs with how you manage fixtures in Environment Parity & Mock Data Management.

10 sections URL: /network-api-mocking-for-reliable-tests/playwright-route-mocking-strategies/tearing-down-msw-service-workers-between-tests/
MSW handler lifecycle across tests Timeline showing listen at start, resetHandlers between tests to drop runtime handlers, and close at the end. listen() beforeAll test A + use(...) resetHandlers test B (clean) resetHandlers close() afterAll
listen once, resetHandlers between every test to drop runtime overrides, close at the end to free the worker.

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 matching resetHandlers() — overrides stack up. Mitigation: put resetHandlers() in a global afterEach, not per file.
  • Skipping close() in watch mode — the worker survives reloads. Mitigation: always pair listen() with afterAll(close).
  • Using onUnhandledRequest: 'bypass' — escaped requests pass silently and hide gaps. Mitigation: use 'error' in tests so unmocked calls fail loudly.
  • Re-creating setupServer per 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/stop for setupWorker exactly 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.