Developer Tools

Local API Mocking: Setting Up MSW (Mock Service Worker) for Fast Integration Tests

By DexNox Dev Team Published May 18, 2026

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:

  1. Registration: The application registers the service worker script using navigator.serviceWorker.register().
  2. Installation: The browser downloads and evaluates the script. If syntax validation succeeds, the worker transitions to the installed state.
  3. 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.
  4. Interception: The active worker listens to fetch events, matching request signatures to local handlers.

Comparison of Mocking Strategies

Choosing the right mocking tool directly impacts the fidelity of your integration tests:

StrategyInterception LayerAPI Standard ConformanceCORS HandlingSetup Overhead
Manual fetch stubsJS Runtime ObjectLow (manual stub objects)IgnoredLow
Axios Mock AdapterLibrary instance hookMedium (Axios-only hooks)IgnoredLow
MSW (Mock Service Worker)Browser Network LayerHigh (Native Request/Response)Verified NativelyMedium

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.

Frequently Asked Questions

Can MSW intercept native Node database connection requests directly?

No. MSW operates at the network protocol layer (HTTP/HTTPS/WebSocket). It intercepts outgoing fetch and XMLHttpRequest calls from your application code. It cannot intercept system-level TCP/UDP socket connections made directly by database drivers like pg, mysql2, or mongoose.

Why do service worker mock configurations crash in private browser windows?

Some browser extensions or incognito window policies restrict service worker registration. MSW's browser integration relies on the Service Worker API, which requires a secure context (HTTPS or localhost) and is blocked in some private browsing modes. The Node.js integration via setupServer is not affected by this.

How do I reset handlers between tests to prevent state leakage?

Call server.resetHandlers() in an afterEach block to remove any handlers added during individual tests via server.use(). This resets to the baseline handlers passed to setupServer() without stopping the server. Use server.close() only in afterAll to fully shut down.

Can I use MSW handlers to simulate network errors and timeouts?

Yes. Use HttpResponse.error() to simulate a network-level failure (connection refused), or return a delayed response using await new Promise(resolve => setTimeout(resolve, 5000)) before returning a response to simulate timeout behavior. Both approaches test how your application handles real failure modes.