Mock Service Worker (MSW) is set up with setupWorker in the browser and setupServer in Node. What actually intercepts the request in each environment, and why can both be handed the same array of handlers?
answer
- what to mock vs how it is caught
- one environment has no service worker
- the other registers a real worker script
- primitives get patched in Node
- handlers are plain, portable values
basics
~20 sIn the browser a registered service worker script catches outgoing requests and asks the MSW client to resolve them. In Node there is no service worker, so MSW patches the request-issuing primitives instead. Handlers are environment-free values, so both setups share them.
solid answer
~50 sTwo different interception mechanisms, one shared description of the API. `setupWorker` (from `msw/browser`) registers a real service worker — a `mockServiceWorker.js` file you serve from the app's public directory — which sits between the page and the network, forwards each outgoing request to the MSW client running on the page, and replies with whatever the matching handler returned. Because it is a genuine network round trip, devtools show the request. `setupServer` (from `msw/node`) runs where no service worker exists — a jsdom or Node test process — so MSW patches the request primitives themselves: `http`/`https`, `XMLHttpRequest` and `fetch`. What both share is the handler array: a handler is a plain value describing method, path and response, with no coupling to how the interception happens. So `handlers.js` is written once and passed to `setupWorker(...handlers)` in the browser and `setupServer(...handlers)` in tests.
code
javascript · 9 lines// test-setup.js — same handlers, Node interception
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())go deeper
Know the pairing: setupWorker for the browser, setupServer for Node or jsdom tests, and that both take the very same handler array you wrote once.
Explain the two mechanisms concretely — a registered worker script forwarding requests to the page's MSW client, versus patching http, XMLHttpRequest and fetch inside the process — and why jsdom forces the second.
Discuss what each mechanism costs you in fidelity, speed and debuggability, and how you structure a repo so development-mode fixtures and test fixtures are literally the same module rather than two drifting copies.
Own the decision of where the fake network boundary lives across a whole codebase, including whether browser-mode mocking is a supported development workflow and how you stop mock startup code from ever reaching a production bundle.
## One description, two interception mechanisms MSW's design separates *what to mock* from *how the request is caught*. The handler array is the what; `setupWorker` and `setupServer` are the two hows, chosen by environment. ## The browser: a real service worker A service worker is a browser-provided script that can sit between a page and the network and observe or answer its requests. MSW ships a generated worker script — `mockServiceWorker.js`, copied into your public directory by its CLI — that does nothing clever on its own: it forwards every request the page makes to the MSW client library running on the page, waits for a verdict, and either replies with the mocked response or lets the request continue to the real network. ```js import { setupWorker } from 'msw/browser' import { handlers } from './handlers' const worker = setupWorker(...handlers) await worker.start() ``` Three consequences follow. First, the request is real all the way out of the application: devtools show it in the network panel, timing exists, and the response your code parses came back through the same path a production response would. Second, the worker script must be reachable at the right URL — if it is not served from the public directory the app is served from, `start()` cannot register it and nothing is mocked. Third, `worker.start()` is asynchronous, so requests fired before it settles escape interception; that is why app entry points await it before rendering, and why a test that renders too early sees real network calls. This setup is what you use for in-browser work: a development mode that runs the UI against fixtures with no backend, a component workshop, or an in-browser component-test runner. ## Node: patching the request primitives A jsdom test environment is not a browser. It has no service worker implementation at all, so the browser mechanism simply cannot apply. `setupServer` — the name is misleading; it starts no HTTP server — instead instruments the objects that issue requests inside the process: Node's `http`/`https` request machinery, the `XMLHttpRequest` jsdom provides, and global `fetch`. Any request made through those, by your code or by a client library sitting on top of them, is routed to the same handler list. ```js import { setupServer } from 'msw/node' import { handlers } from './handlers' export const server = setupServer(...handlers) ``` The lifecycle is explicit and belongs in the suite's setup file: start the interception before the tests run, drop per-test overrides between tests, and stop the interception at the end. Because interception is process-wide, forgetting to stop it leaks into whatever runs next in the same process. ## Why the handlers are shared A handler carries no knowledge of its environment. `http.get('/api/users', () => HttpResponse.json(users))` names a method, a path and a response — the same three facts whether the request is caught by a service worker or by a patched `fetch`. So the honest project layout is one `handlers.js` (or a folder of them, one per resource) exported as a plain array, imported by `browser.js` for `setupWorker` and by the test setup file for `setupServer`. That sharing is the practical payoff. The fixtures your unit tests assert against are the same fixtures the app renders in offline development mode, so a stale fixture is discovered by whoever hits it first, and the two never drift into two competing pictures of the API. ## What differs in practice Fidelity: the worker path exercises the whole browser network stack, including things a patched `fetch` does not perfectly reproduce; the Node path is faster and needs no browser. Debuggability: the worker shows requests in devtools, while in Node you rely on MSW's lifecycle events and console output. Scope: the worker only intercepts requests from pages it controls, whereas `setupServer` covers everything issued in the process, including requests from server-side code in the same run. Timing: `worker.start()` must be awaited; `server.listen()` is synchronous. ## The interview point The answer an interviewer is listening for is not the two function names — it is that the *same declarative handlers* are portable across environments because interception is an implementation detail, and that you know which environment forces which mechanism and why. A candidate who says "setupServer spins up a mock HTTP server on a port" has the mental model wrong: nothing listens on a port, and no URL changes.
- Why does 'setupServer' not actually start a server, and what would change if it did?It starts interception inside the current process, not a listener on a port — no URL changes, and the app still calls its production endpoints. A real mock server would mean pointing the app at a different base URL in tests, which is configuration the shipped code does not have, and it would leave the process-level plumbing untested. The name is historical.
- A browser test asserts on a fixture but the real API is hit instead. What is the first thing you check about the worker?Whether `worker.start()` resolved before the request was made — it is asynchronous, and anything fired earlier escapes interception, so the app entry should await it before rendering. Next, whether `mockServiceWorker.js` is actually being served from the public directory at the scope the page runs under; if registration fails, nothing is intercepted and the request goes straight out.
- Does using MSW in browser development mode risk shipping mocks to production?Only if the start call is unconditional. The convention is to import and start the worker behind an environment check so the code is stripped or skipped in a production build. The worker script itself is inert unless a page registers it, but the real safeguard is that the start call never runs outside development.
saying these in an interview costs you the question
- Claims setupServer starts a real HTTP server on a port
- Thinks a service worker also works inside jsdom tests
- Says the app must point at a different base URL in tests
- Forgets worker.start() is async and must be awaited
- Believes handlers must be rewritten per environment