When your frontend code makes API calls during development or tests, you have two bad options: hit a real backend that may be unstable or unavailable, or mock fetch directly in each test file. Mock Service Worker (MSW) is a third option that intercepts requests at the network level using the browser’s Service Worker API or Node.js interceptors, giving you real network behavior without a real server.
Service Worker Interception Architecture
MSW registers a service worker in the browser that intercepts all outgoing HTTP requests before they leave the application. The worker matches requests against your handler definitions and either returns a mocked response or passes the request through to the actual network. In Node.js, MSW patches the global fetch and XMLHttpRequest at the module level to achieve the same interception without a worker.
This approach differs from stubbing fetch manually (global.fetch = jest.fn()) in a critical way: your application code runs against the real fetch API with real Response objects, timing behavior, and error modes. Bugs caused by incorrect response handling that mock-fetch stubs silently absorb will surface with MSW.
Browser Network Call ──▶ Service Worker Thread ──▶ Handler Matching Check
│
┌─────────────── Unmatched Request ───────────────┼─── Matched Mock Request ──┐
▼ ▼ ▼
Pass Through to Web Parse variables Return HttpResponse
The Service Worker Registration and Activation Lifecycle
The browser executes service workers in a separate execution thread. During startup, the browser goes through four distinct states:
- Registration: The application registers the service worker script using
navigator.serviceWorker.register(). - Installation: The browser downloads and evaluates the script. If syntax validation succeeds, the worker transitions to the installed state.
- Activation: The worker takes control of clients within its scope. During local development, MSW triggers
clients.claim()during activation to immediately intercept active network tasks without requiring a page reload. - Interception: The active worker listens to
fetchevents, matching request signatures to local handlers.
Comparison of Mocking Strategies
Choosing the right mocking tool directly impacts the fidelity of your integration tests:
| Strategy | Interception Layer | API Standard Conformance | CORS Handling | Setup Overhead |
|---|---|---|---|---|
| Manual fetch stubs | JS Runtime Object | Low (manual stub objects) | Ignored | Low |
| Axios Mock Adapter | Library instance hook | Medium (Axios-only hooks) | Ignored | Low |
| MSW (Mock Service Worker) | Browser Network Layer | High (Native Request/Response) | Verified Natively | Medium |
Installation
Install the core package using your preferred package manager:
npm install msw --save-dev
# or
bun add -d msw
MSW v2 requires Node.js 18+ and modern browsers. It ships TypeScript types without separate @types packages.
Defining REST and GraphQL Handlers
Create a shared handlers file that both browser and Node environments import:
// src/mocks/handlers.ts
import { http, HttpResponse, graphql } from "msw";
export interface User {
id: number;
name: string;
email: string;
role: "admin" | "member";
}
export interface Post {
id: number;
title: string;
authorId: number;
}
export const handlers = [
// REST: GET /api/users
http.get("/api/users", () => {
return HttpResponse.json<User[]>([
{ id: 1, name: "Alice Chen", email: "alice@example.com", role: "admin" },
{ id: 2, name: "Bob Marsh", email: "bob@example.com", role: "member" },
]);
}),
// REST: GET /api/users/:id
http.get("/api/users/:id", ({ params }) => {
const { id } = params;
if (id === "999") {
return HttpResponse.json({ error: "User not found" }, { status: 404 });
}
return HttpResponse.json<User>({
id: Number(id),
name: "Alice Chen",
email: "alice@example.com",
role: "admin",
});
}),
// REST: POST /api/users (Multipart / Form-Data Upload Mock)
http.post("/api/users/avatar", async ({ request }) => {
const data = await request.formData();
const file = data.get("avatar") as File;
if (!file) {
return HttpResponse.json({ error: "No file uploaded" }, { status: 400 });
}
return HttpResponse.json({
fileName: file.name,
fileSize: file.size,
status: "uploaded",
});
}),
// GraphQL query handler
graphql.query("GetPost", ({ variables }) => {
const { postId } = variables as { postId: number };
return HttpResponse.json({
data: {
post: { id: postId, title: "Mock Post Title", authorId: 1 },
},
});
}),
// GraphQL mutation handler
graphql.mutation("CreatePost", async ({ variables }) => {
const { input } = variables as { input: { title: string; authorId: number } };
return HttpResponse.json({
data: {
createPost: {
id: 42,
title: input.title,
authorId: input.authorId,
},
},
});
}),
];
Mocking Advanced Stream Formats
1. Server-Sent Events (SSE) Stream Mocking
You can simulate live Server-Sent Events by returning a ReadableStream with the appropriate text/event-stream headers:
// src/mocks/sse-handler.ts
import { http, HttpResponse } from "msw";
export const sseHandler = http.get("/api/sse-events", () => {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// Send initial connection event
controller.enqueue(encoder.encode("event: open\ndata: connection established\n\n"));
// Stream updates at intervals
let counter = 0;
const interval = setInterval(() => {
counter++;
controller.enqueue(encoder.encode(`event: update\ndata: {"tick": ${counter}}\n\n`));
if (counter >= 5) {
clearInterval(interval);
controller.close();
}
}, 500);
}
});
return new HttpResponse(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
}
});
});
2. Mocking WebSockets Connections
MSW v2 supports intercepting WebSockets connections, allowing you to mock duplex messaging channels:
// src/mocks/websocket-handler.ts
import { ws } from "msw";
const chatNamespace = ws.link("ws://localhost:8080/chat");
export const websocketHandler = chatNamespace.addEventListener("connection", ({ client }) => {
// Respond to connection
client.send(JSON.stringify({ event: "connected", userId: client.id }));
// Echo back incoming client messages
client.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
if (data.text === "ping") {
client.send(JSON.stringify({ text: "pong" }));
}
});
});
Browser Integration
Initialize the Service Worker for browser-based development. Copy the worker script to your public directory:
npx msw init public/ --save
The --save flag ensures the worker file updates automatically when the package updates.
Create the browser-specific setup:
// src/mocks/browser.ts
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers.js";
export const worker = setupWorker(...handlers);
Start the worker in your application entry point, guarded by an environment check:
// src/main.ts
async function enableMocking(): Promise<void> {
if (process.env.NODE_ENV !== "development") return;
const { worker } = await import("./mocks/browser.js");
await worker.start({
onUnhandledRequest: "warn",
serviceWorker: {
url: "/mockServiceWorker.js",
},
});
}
await enableMocking();
Node.js / Testing Integration
For Vitest and Jest, use the setupServer function from msw/node:
// src/mocks/node.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers.js";
export const server = setupServer(...handlers);
Configure Vitest setup files to lifecycle control the mock server:
// vitest.setup.ts
import { beforeAll, afterEach, afterAll } from "vitest";
import { server } from "./src/mocks/node.js";
beforeAll(() => {
server.listen({ onUnhandledRequest: "error" });
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
Register the setup file in vitest.config.ts:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
setupFiles: ["./vitest.setup.ts"],
globals: true,
},
});
What Breaks in Production
1. Relative Request Mapping Crashes
When running tests, the execution environment (Node.js/Vitest) runs without a browser domain host. If your code makes a request to /api/users, the native fetch client in Node.js cannot resolve the target domain, throwing an invalid URL error.
- Resolution: Use absolute URL structures (e.g.,
process.env.API_URL || "http://localhost:3000") inside your application API client configurations.
2. Service Worker File Path Caching Errors
If the mockServiceWorker.js file is cached by the browser’s HTTP cache or service worker cache, updating the MSW library package version can result in version mismatch warnings, preventing interception from working.
- Resolution: Configure local development servers to serve the worker script file with caching headers disabled (
Cache-Control: no-store).
3. Incognito Mode Registration Blocks
Some browsers disable the Service Worker API when executing scripts inside private browsing tabs or incognito windows to prevent user tracking. This blocks MSW from intercepting calls.
- Resolution: Fall back to using standard mock APIs or check for service worker registration support before launching the worker process.
4. Fetch Client Interceptor Context Leakage
If tests execute asynchronously and modify baseline mock responses using server.use() without executing server.resetHandlers() inside the tear-down hooks, subsequent tests will receive stale mock data.
- Resolution: Always declare the cleanup reset calls (
afterEach(() => server.resetHandlers())) in the global test setup files.
Frequently Asked Questions
Can I mock third-party external API requests?
Yes, you can register handlers targeting external domains by declaring the absolute URL path (e.g., http.get("https://api.stripe.com/v1/*")) in the handler array. This enables you to simulate payment gateways, external auth providers, and CDN endpoints directly without letting requests escape to the public internet during test execution.
Why do some GraphQL mocks fail to match variables?
MSW matches GraphQL operations by query or mutation names. Ensure that the client-side query string contains the exact operation name matched in your mock handler. If the operation name in the client request does not exactly match the string defined in the handler (including character casing), MSW will skip the handler and pass the request to the real network.
How do I log all intercepted requests for debugging?
Set the onUnhandledRequest option inside your worker or server start options to warn or provide a custom callback function to log every intercepted request. You can also hook into the worker’s lifecycle events to log the request body and matched mock responses, which helps in tracing failed matches during local integration testing.
What is the performance impact of MSW on Vitest test suites?
MSW adds a small overhead of roughly 5-10ms per test due to in-memory request interception. Since it runs directly inside the Node process without a local TCP socket server, it executes significantly faster than starting a mock HTTP server like Express, helping keep integration test runtimes low.