CodeWorlds
Back to collections
Guide15 min readCodeWorlds Team

MSW, network mocks for tests and the browser

MSW intercepts requests at the network layer, so the same mocks run in Node and in the browser. Version 2.15.0, the worker file, and the real costs.

MSW, network mocks for tests and the browser

MSW intercepts HTTP requests at the network layer instead of swapping out the fetch function or the client module inside your application code. The same set of handlers serves tests in Node, the application running in a browser, and stories in Storybook. The current version is 2.15.0, released on 8 July 2026, licensed under MIT, repository mswjs/msw.

How the interception works

The difference from module swapping is mechanical, not aesthetic. When a test calls vi.mock('./api-client') or assigns your own function to globalThis.fetch, you are testing code that is no longer the same code. The layer you replaced stops existing, along with its header handling, retries, request body serialisation, and status code interpretation. The application knows it is being tested, because it received a different object than it does in production.

MSW works one floor below. In the browser it registers a Service Worker that catches the fetch event on the browser side and passes the request back into the page, where your handlers run and the result returns as an ordinary HTTP response. In Node it does the same through the @mswjs/interceptors package, which wraps the built in http module, XMLHttpRequest, and the global fetch. In both cases the application code is untouched: no injected client, no if (process.env.MOCK) branch, no code path that exists only in tests.

The practical consequence is that you test your real HTTP client. If you use axios with an interceptor attaching a token, that interceptor runs. If TanStack Query retries after a 500 response, the retry actually goes out and hits the handler a second time. That is an advantage and a trap at the same time, because tests stop being as flatly deterministic as they are with a stubbed function returning a fixed value.

What MSW does not do deserves saying plainly. It does not check whether your mocks match what the server really returns. Handlers are your idea of the API, and ideas go stale. A green test suite built on MSW agrees with the contract as it stood six months ago, if nobody updated the handlers. Catching that requires contract tests or mocks generated from an OpenAPI schema, and those are separate tools.

Version, license, and project status

Version 2.15.0 came out on 8 July 2026, with the previous one, 2.14.7, a day earlier. The repository has roughly 18.1 thousand stars, 617 forks, and 41 open issues, it is not archived, and the last change on the main branch dates to 24 July 2026. The npm registry lists a single package maintainer, kettanaito, and that is the most serious organisational risk around this library. There is no paid tier and no support agreement, funding runs through GitHub Sponsors, and the project rests on one person plus a handful of contributors.

The license is a rare case of three sources agreeing completely. The LICENSE.md file in the repository carries the MIT text with copyright held by Artem Zakharchenko. The license field in the npm registry reads MIT. The published package, 1.24 MB across 670 files, ships that same LICENSE.md in its root directory. The @mswjs/interceptors dependency, which everything on the Node side rests on, is MIT as well.

There is one detail, though, that only surfaces when you audit your own repository. The mockServiceWorker.js file that MSW copies into your public directory, and which then lands in your repository, carries no license notice in its header. It has only a comment with the project name, a link to GitHub, and a request not to modify it. A license scanner walking your code will therefore see a several hundred line file of foreign authorship with no attribution. If your company produces a software bill of materials, add it by hand or exclude it deliberately rather than by oversight.

Dependency weight is real. The package declares eighteen production dependencies, among them graphql at ^16.13.2, yargs, tough-cookie, and @inquirer/confirm. The graphql package lands in your node_modules even if you never mock a single GraphQL query. The engines field requires Node 18 or newer, and typescript at >= 4.8.x is a peer dependency marked optional.

Version 2.15.0 also shows signs of an approaching interface rebuild. The waitUntilReady option is marked deprecated, the LifeCycleEventsMap type gives way to HttpNetworkFrameEventMap, and the SetupServerCommonApi class is deprecated in favour of the defineNetwork interface available under the msw/experimental export path. The library is stable in daily use, but when planning a larger rollout, budget for migration work in the next major version.

Installation and the worker file

Installation is two commands, and the second is the one everybody forgets.

Code
Bash
npm install --save-dev msw

# copies mockServiceWorker.js into the given public directory
npx msw init ./public --save

The init command takes a directory path plus two options: --save, which records that path in your package.json, and --cwd, which points at the project directory when you run the command from somewhere else. The result of --save is an entry that looks like this.

Code
JSON
{
  "name": "my-application",
  "msw": {
    "workerDirectory": ["public"]
  }
}

That entry is not cosmetic. The msw package has a postinstall script that reads the package.json from the directory named by the INIT_CWD variable, checks for the msw.workerDirectory key, and if it finds one, runs init again to refresh the worker file after a library upgrade. Without that entry the script exits immediately and does nothing.

This is where the most common build server failure comes from. Installing with the --ignore-scripts flag, common in hardened continuous integration pipelines, skips that script, so the public directory keeps a worker from an older version. The library detects this on its own: the worker file holds an INTEGRITY_CHECKSUM constant, compared against the value baked into the library, and on a mismatch a console warning appears telling you to run init again.

The cost of this arrangement is exactly what it looks like. Your public directory holds an extra file served at /mockServiceWorker.js, which must not be modified and has to live in the repository. A Service Worker applies within the scope set by the directory it is served from, so an application hosted under a subpath needs its own address passed in the serviceWorker.url option. The Node test environment does not use that file at all, which means two separate configuration paths for one set of handlers.

Handlers, the core of the setup

Handlers are shared across every environment and live in one file. Below is a set using real names from the 2.15.0 interface.

TSsrc/mocks/handlers.ts
TypeScript
// src/mocks/handlers.ts
import { http, HttpResponse, graphql, delay, passthrough } from 'msw'

export const handlers = [
  http.get('/api/projects/:projectId', ({ params, cookies, request }) => {
    if (!cookies.sessionToken) {
      return new HttpResponse(null, { status: 401 })
    }

    return HttpResponse.json({
      id: params.projectId,
      name: 'Catalogue migration',
      ownerId: 'user-42',
      updatedAt: '2026-08-19T09:12:00.000Z'
    })
  }),

  http.post('/api/projects', async ({ request }) => {
    const payload = (await request.json()) as { name: string }

    await delay(120)

    return HttpResponse.json({ id: 'project-77', name: payload.name }, { status: 201 })
  }),

  http.get('/api/reports/export', () => HttpResponse.error(), { once: true }),

  graphql.query('ListMembers', () =>
    HttpResponse.json({
      data: { members: [{ id: 'user-42', role: 'OWNER' }] }
    })
  ),

  http.get('https://cdn.example.com/*', () => passthrough())
]

Several things in that code behave differently from what intuition suggests. The resolver receives an object with request, requestId, params, and cookies fields, where params is typed from the path pattern and cookies is a plain record of strings. You return a standard Response object, and the HttpResponse class is merely a convenient wrapper with json, text, xml, html, arrayBuffer, formData, and error methods.

The { once: true } option makes a handler answer a single time, after which the next matching handler takes over. That is how you test retries: the first attempt fails, the second comes back clean. The delay function takes a number of milliseconds or one of the real and infinite modes, the latter never responding and serving to exercise loading states. The passthrough function lets a request reach its real destination, which helps with static assets and third party domains.

Path matching rests on path-to-regexp version 6, so :projectId and the asterisk behave the way they do in a typical router. A relative path matches against the current page address, an absolute address matches literally. Beyond http and graphql the library also offers ws for WebSockets and sse for server sent events.

Node, Vitest, and the test boundary

For tests running in Node you use setupServer from the msw/node subpath. The name misleads, because nothing here listens on a port, it is simply an interception switch.

TSsrc/mocks/node.ts
TypeScript
// src/mocks/node.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
TSvitest.setup.ts
TypeScript
// vitest.setup.ts
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './src/mocks/node'

beforeAll(() => {
  server.listen({ onUnhandledRequest: 'error' })
})

afterEach(() => {
  server.resetHandlers()
})

afterAll(() => {
  server.close()
})

Setting onUnhandledRequest to error is the single decision that raises the value of such a suite the most. Accepted values are bypass, warn, error, and a custom callback, with warn as the default. Under warn a request that no handler covered flies out to the real network, and the test passes or fails depending on whether the connection happens to be up. Under error you learn straight away that something is unmocked.

To override a response inside a single test you use server.use, and server.resetHandlers in afterEach restores the initial state. With tests running in parallel inside one process, resetHandlers alone is not enough, because two tests can overwrite each other's handlers. That is what server.boundary is for: it wraps a function and confines network changes to that call.

Code
TypeScript
import { expect, test } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from './src/mocks/node'

test(
  'shows a message when the project list is unavailable',
  server.boundary(async () => {
    server.use(
      http.get('/api/projects/:projectId', () => new HttpResponse(null, { status: 503 }))
    )

    server.events.on('request:unhandled', ({ request }) => {
      console.warn('no handler for', request.url)
    })

    const response = await fetch('/api/projects/project-77')
    expect(response.status).toBe(503)
  })
)

The server.events emitter carries the request:start, request:match, request:unhandled, request:end, response:mocked, and response:bypass events. When diagnosing a test that behaves oddly, subscribing to request:unhandled points at the culprit faster than reading application logs. Configuration for Vitest comes down to naming the setup file in the setupFiles field, and for projects in TypeScript the types ship with the package, with no separate declarations bundle.

Browser, Storybook, and React Native

In the browser you use setupWorker from the msw/browser subpath instead of setupServer, and startup is asynchronous, because registering a Service Worker takes a moment.

TSsrc/mocks/browser.ts
TypeScript
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)

// src/main.tsx
async function enableMocking() {
  if (process.env.NODE_ENV !== 'development') {
    return
  }

  const { worker } = await import('./mocks/browser')

  return worker.start({
    onUnhandledRequest: 'bypass',
    quiet: false,
    serviceWorker: {
      url: '/mockServiceWorker.js'
    }
  })
}

enableMocking().then(() => {
  renderApplication()
})

The environment check and the dynamic import together keep MSW out of the production bundle. That matters, because a setupWorker call started by accident in production will intercept traffic for actual users. The quiet option silences the logging of intercepted requests in the console, and findWorker lets you supply your own lookup function among registered workers when the application already has one. The worker.start call returns a promise that must be awaited before rendering, otherwise the first application requests go out before the worker becomes active.

The same pair of files serves Storybook, where worker.start runs once in the preview configuration and individual stories override responses through worker.use. A React component shown in the component catalogue and the same component in a unit test then receive identical data, without two sets of sample objects drifting apart after a few weeks.

React Native has its own msw/native subpath, exporting a setupServer adapted to an environment with no Service Worker. The package also exposes msw/core/http, msw/core/graphql, and msw/core/ws, useful when you build your own helper layer and would rather not pull in the whole package root.

MSW against the alternatives

ToolInterception layerWhere it runsSame mocks reused elsewhere
MSWService Worker in the browser, module interceptors in NodeNode tests, browser, Storybook, React Nativeyes, one handler file
nockthe built in http module in NodeNode onlyno, does not run in a browser
page.route in Playwrightbrowser context driven by the toolPlaywright tests onlyno
cy.intercept in Cypressa proxying layer in front of the browserCypress tests onlyno
swapping globalThis.fetcha single global functionanywhere code calls fetch directlypartly, misses HTTP clients
local stub servera real TCP portanywhere, once the base address changesyes, at the cost of a process and config

The choice is not binary and these tools do not exclude one another. In a project with unit tests in Vitest, a component catalogue in Storybook, and browser tests, MSW ties the first two environments together with one set of handlers, while full user journey tests are still better run against a real backend. If you write only a Node backend and never mock anything in a browser, nock is lighter and needs no file in a public directory.

The strongest argument for MSW appears once the same set of mocks starts serving several consumers. One source of truth about what the API returns, instead of three diverging copies across tests, Storybook, and the backend free development mode.

Common mistakes

The first is an outdated worker file. After bumping the library version the public directory keeps the old mockServiceWorker.js, and an install that skips scripts will not refresh it. The symptom is a checksum mismatch warning or odd behaviour on some requests. The cure is another npx msw init ./public --save.

The second is leaving onUnhandledRequest at its default. The default is warn, so an unmocked request goes out to the real network and the test is either slow or failing depending on the time of day. Set error in tests, and usually bypass in the browser during development.

The third is not awaiting worker.start. The method returns a promise, and the Service Worker needs a moment to activate. Rendering the application before it resolves lets the first few requests slip past the mocks and end in a 404 from the development server.

The fourth is mixing handler scope between tests. Without server.resetHandlers in afterEach, an override from one test carries into the next, and with parallel tests you also need server.boundary, because the reset alone does not separate states.

The fifth is treating MSW as proof of API compliance. Green tests say only that the code works against your idea of the server response. Responses generated from a schema, or contract tests, are a separate safeguard that this library does not replace.

The sixth is forgetting Service Worker scope for an application served from a subpath. The worker covers only paths below the directory it was fetched from, so an application under a prefixed address needs a correct serviceWorker.url, otherwise registration succeeds while interception never happens.

FAQ

Is MSW suitable for production use?

It is not meant for that, and running it in production means intercepting traffic for real users. Keep the worker startup code behind an environment check and a dynamic import so the library never enters the production bundle.

How is this different from swapping fetch in Vitest?

Swapping fetch removes the entire HTTP client layer from the test along with its logic. MSW leaves it intact and substitutes the response only at the network level, so your interceptors, retries, and header handling all run inside the test. That same mock definition then works in the browser.

Does the worker file have to live in the repository?

Yes, mockServiceWorker.js has to sit in the public directory and be served alongside the application, so it goes into the repository. The init command refreshes it, running automatically after installation when package.json carries the msw.workerDirectory key.

Does MSW work with axios and Apollo Client?

Yes, because interception happens below those libraries. Axios uses XMLHttpRequest in the browser and the http module in Node, Apollo Client uses fetch, and every one of those routes is covered. GraphQL requests can also be described with graphql.query and graphql.mutation handlers rather than matched by address.

What do I do when a request matches no handler?

Set onUnhandledRequest to error and subscribe to the request:unhandled event through server.events.on to see the exact address. The usual causes are a relative path where an absolute one was needed for a cross domain request, and a typo in a path parameter name.

The full interface is described in the MSW documentation, with source code and issues in the GitHub repository.

Read next

We use cookies to enhance your experience on the site