Request Interception Patterns

This page covers how to capture, route, and conditionally handle outbound HTTP requests during local development and automated testing — it does not cover response generation or latency simulation (those belong to response shaping techniques).

Prerequisites

  • Node.js 18+ (native fetch available without polyfill)
  • msw 2.x installed (npm install msw --save-dev) or a WireMock-compatible proxy running locally
  • MOCK_API environment variable defined in .env.local (set to true for local, false for staging)
  • Basic familiarity with Service Workers (for browser-side MSW) or Docker Compose (for proxy-side WireMock)
  • Understanding of when to use each approach — see proxy vs inline mocking strategies

How request interception fits the local-dev stack

Before writing any handler code it helps to see where interception sits in the overall traffic flow. The diagram below shows both the client-side (inline) path and the network-level (proxy) path for a single outbound request:

Request Interception Architecture Two interception paths: client-side (Service Worker / MSW inline hook intercepts the fetch call before it leaves the browser) and network-level (request leaves the browser but is captured by a local proxy or WireMock before reaching the real API). Browser / Node fetch() / XHR http.request() Path A — client-side Service Worker (MSW inline hook) Mock Response (handler resolver) response returned to caller Path B — network-level proxy Local Proxy (WireMock / Nginx) Mock Response (stub / mapping) Real API (passthrough) unmatched requests response returned to caller Browser / Node fetch() / XHR

Path A (client-side) intercepts before the request leaves the JavaScript runtime. Path B (network-level) lets the request travel over the loopback interface but captures it at a local proxy. Unmatched requests on Path B pass through to the real API; on Path A they are forwarded only if you explicitly call passthrough().


Phase 1 — Core setup: MSW handler registration

For browser-based React or Next.js apps, MSW’s Service Worker approach is the fastest way to intercept fetch calls without touching application code.

Install and initialise the worker script:

npx msw init public/ --save

Create your handler file (src/mocks/handlers.ts):

import { http, HttpResponse } from 'msw'

export const handlers = [
  // Exact path — highest precedence
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'Ada Lovelace',
      role: 'engineer',
    })
  }),

  // Regex path — matches /api/products/123 and /api/products/abc
  http.get(/\/api\/products\/[\w-]+/, ({ request }) => {
    const url = new URL(request.url)
    return HttpResponse.json({
      sku: url.pathname.split('/').pop(),
      inStock: true,
    })
  }),

  // Header-conditional handler — inspect Accept or custom headers
  http.post('/api/reports', async ({ request }) => {
    const accept = request.headers.get('Accept') ?? ''
    if (accept.includes('text/csv')) {
      return new HttpResponse('id,name\n1,Ada', {
        headers: { 'Content-Type': 'text/csv' },
      })
    }
    return HttpResponse.json({ report: 'generated' })
  }),

  // Passthrough — unmatched third-party calls reach the real network
  http.all('https://cdn.example.com/*', ({ request }) => {
    return fetch(request)
  }),
]

Register the worker in your app entry point (src/main.ts):

async function prepareApp() {
  if (process.env.NODE_ENV === 'development' || process.env.MOCK_API === 'true') {
    const { worker } = await import('./mocks/browser')
    await worker.start({
      onUnhandledRequest: 'warn', // surface accidental passthrough in the console
    })
  }
}

prepareApp().then(() => {
  // mount your React/Vue/Svelte app here
})

Create the browser entry (src/mocks/browser.ts):

import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)

For Node.js / SSR environments (Vitest, Jest, Next.js API routes), swap the worker for a server:

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

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

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

Setting onUnhandledRequest: 'error' in CI is deliberate: it converts an accidental missing handler into a test failure, which is far easier to diagnose than a silent network timeout.


Phase 2 — Configuration and wiring

Environment flag discipline

Never rely on a single NODE_ENV check — it conflates development, test, and preview environments. Use a dedicated flag:

# .env.local (developer machine)
MOCK_API=true

# .env.test (Vitest / Jest)
MOCK_API=true

# .env.staging (Cloudflare Pages preview / Vercel preview)
MOCK_API=false

Read the flag at startup, not at the handler level, so dead code elimination can strip the mock bundle from production builds:

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  define: {
    // Booleanise so Rollup can tree-shake the false branch
    '__MOCK_API__': JSON.stringify(process.env.MOCK_API === 'true'),
  },
})
// src/main.ts (updated)
declare const __MOCK_API__: boolean

async function prepareApp() {
  if (__MOCK_API__) {
    const { worker } = await import('./mocks/browser')
    await worker.start({ onUnhandledRequest: 'warn' })
  }
}

Matcher precedence rules

MSW evaluates handlers in the order they were registered — first match wins. Structure your handler file top-to-bottom:

  1. Exact path (/api/users/me) — no ambiguity, always first.
  2. Path with named parameters (/api/users/:id) — before regex.
  3. Regex paths (/\/api\/orders\/\d+/) — after named params.
  4. Wildcard catch-all (/api/*) — last; use sparingly.
  5. http.all passthrough — at the very end of the file.

For WireMock, stub priority is explicit. Set priority on each mapping — lower numbers take precedence:

{
  "priority": 1,
  "request": { "method": "GET", "url": "/api/users/me" },
  "response": { "status": 200, "jsonBody": { "id": "me", "name": "Ada" } }
}
{
  "priority": 5,
  "request": { "method": "GET", "urlPathPattern": "/api/users/[^/]+" },
  "response": { "status": 200, "jsonBody": { "id": "{{request.pathSegments.[2]}}", "name": "Generic User" } }
}

Payload validation in handlers

Intercept and validate request bodies before returning a response. This catches contract drift early — the same principle underpins the broader discussion in mock lifecycle management:

import { http, HttpResponse } from 'msw'
import { z } from 'zod'

const CreateOrderSchema = z.object({
  items: z.array(z.object({ sku: z.string(), qty: z.number().int().positive() })),
  shippingAddressId: z.string().uuid(),
})

http.post('/api/orders', async ({ request }) => {
  let body: unknown
  try {
    body = await request.json()
  } catch {
    return HttpResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
  }

  const parsed = CreateOrderSchema.safeParse(body)
  if (!parsed.success) {
    return HttpResponse.json(
      { error: 'Validation failed', issues: parsed.error.flatten() },
      { status: 422 },
    )
  }

  return HttpResponse.json({ orderId: crypto.randomUUID(), status: 'created' }, { status: 201 })
})

Phase 3 — CI pipeline integration

In CI every test run must start with a clean handler set and end with complete teardown to prevent port collisions and state leakage between suites.

GitHub Actions example — runs Vitest against the MSW Node server:

# .github/workflows/test.yml
name: Unit + integration tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      MOCK_API: "true"
      NODE_ENV: "test"
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm test -- --reporter=verbose

Because setupServer in MSW Node runs in-process, there is no port to allocate and no container to start — the entire interception layer lives in memory, which keeps CI jobs fast and deterministic.

For WireMock in Docker Compose (needed when server-side rendering or non-JS services must also use mocked endpoints), align the service definition with your dockerized mock environments setup:

# docker-compose.test.yml
services:
  wiremock:
    image: wiremock/wiremock:3.5.4
    ports:
      - "8080:8080"
    volumes:
      - ./wiremock/mappings:/home/wiremock/mappings:ro
      - ./wiremock/__files:/home/wiremock/__files:ro
    command: ["--port", "8080", "--verbose"]
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/__admin/health"]
      interval: 5s
      timeout: 3s
      retries: 5

  app:
    build: .
    environment:
      API_BASE_URL: "http://wiremock:8080"
      MOCK_API: "true"
    depends_on:
      wiremock:
        condition: service_healthy

The healthcheck block prevents the app container from starting before WireMock has loaded all its stub mappings — a common source of flaky CI runs.


Four places to intercept, four levels of reach Four interception points ordered from closest to the application outward. A client wrapper sees only calls that go through it. A fetch patch sees every fetch but no XHR. A Service Worker sees every page request but nothing below fetch. A proxy sees everything including non-page traffic. Each is annotated with what it cannot reach. Client wrapper only calls that route through your own module misses any direct fetch or XHR Global fetch patch every fetch call in the page misses XMLHttpRequest entirely Service Worker every request the page makes misses documents, iframes and workers Network proxy everything on the wire needs a process and a port Choose the innermost layer that still sees all the traffic you care about — reach costs complexity.

Verification steps

Run these commands after completing the setup above. Each expected output is listed so you know exactly what a passing state looks like.

  • Service worker registration — open the browser DevTools → Application → Service Workers. You should see mockServiceWorker.js listed with status “Activated and running”.
  • Request interception confirmed — open DevTools → Network. Filter by Fetch/XHR. Make a call to /api/users/1. In the response column you should see 200 (from ServiceWorker), not 200 (from cache) or a real network call.
  • Node test suite — run npm test. All tests referencing mocked endpoints should pass. The console should show [MSW] Server started and [MSW] Server closed at the boundaries.
  • Unhandled request detection — temporarily add a fetch('/api/nonexistent') call and run tests. Expect a console warning ([MSW] Warning: captured a request without a matching handler) if onUnhandledRequest: 'warn', or a thrown error if set to 'error'.
  • Environment flag isolation — set MOCK_API=false, restart the dev server, and confirm that requests reach the real API (or fail with a network error if the real API is unreachable). No handler code should execute.
  • WireMock health (if applicable) — run curl -s http://localhost:8080/__admin/health | jq .status. Expected output: "running".
  • Stub listing — run curl -s http://localhost:8080/__admin/mappings | jq '.mappings | length'. The count must equal the number of JSON files in ./wiremock/mappings/.

Reading an interception failure Five symptoms mapped to their usual cause. Nothing intercepted at all usually means the worker never registered. Only the first request escaping means registration raced the render. Some requests escaping means the transport differs from the one patched. A wrong response means two interceptors are competing. A hang means the mock never responded at all. Symptom Usual cause First thing to check nothing is intercepted the worker never registered the worker URL returns JavaScript only the first request escapes registration raced the first render start is awaited before render some requests escape a second transport is in use whether the call uses XHR the wrong response arrives two interceptors are competing for a stray cy.intercept or route the request hangs forever the handler never returned for a missing return in the resolver Four of the five are configuration rather than handler bugs, which is where most debugging time goes wrong.

Troubleshooting

Failed to register a ServiceWorker: The path of the provided scope ('/') is not under the max scope

Cause: The mockServiceWorker.js file is not in the public/ directory (or the equivalent static root for your framework).

Fix: Re-run npx msw init <static-dir> --save pointing at the correct directory. For Vite the default is public/; for Create React App it is also public/; for Next.js App Router it is public/.


TypeError: Cannot read properties of undefined (reading 'json') inside a handler

Cause: The handler is calling request.json() on a request whose Content-Type is multipart/form-data or text/plain.

Fix: Branch on the content type, or use request.formData() / request.text() as appropriate:

http.post('/api/upload', async ({ request }) => {
  const contentType = request.headers.get('Content-Type') ?? ''
  if (contentType.includes('multipart/form-data')) {
    const form = await request.formData()
    const file = form.get('file') as File
    return HttpResponse.json({ filename: file.name, size: file.size })
  }
  const body = await request.json()
  return HttpResponse.json({ received: body })
})

[MSW] Warning: captured a request without a matching handler flooding the console

Cause: A third-party script (analytics, fonts, error tracking) is making requests that are not covered by a handler or explicit passthrough rule.

Fix: Add a wildcard passthrough for each external origin you do not want to mock. Put these at the end of your handler array:

import { http } from 'msw'

export const passthroughHandlers = [
  http.all('https://fonts.googleapis.com/*', ({ request }) => fetch(request)),
  http.all('https://cdn.segment.com/*', ({ request }) => fetch(request)),
]

WireMock returns 404 for a URL you are certain has a mapping

Cause: Most commonly a priority conflict — a lower-priority wildcard mapping is evaluated first and returns a 404 before the specific mapping is reached. Less often, the URL in the mapping uses url (exact match) but the request carries a query string.

Fix: Switch from url to urlPath (ignores query string) or urlPathPattern (regex, also ignores query string). Increase the priority value of your catch-all mappings so specific stubs win.


CORS preflight (OPTIONS) request is not intercepted and the browser blocks the real request

Cause: MSW’s Service Worker intercepts OPTIONS requests but does not add CORS response headers by default. The browser makes the preflight before the actual request; if the preflight fails, the real request is blocked.

Fix: Add an explicit handler for OPTIONS requests or use MSW’s built-in cors helper:

import { http, HttpResponse } from 'msw'

http.options('/api/*', () => {
  return new HttpResponse(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    },
  })
})

When to advance

You have correctly implemented request interception when all of the following are true:

  • Every fetch or XHR call to /api/* from the browser shows (from ServiceWorker) in DevTools Network tab.
  • The Vitest or Jest suite runs in under 10 seconds with no real HTTP calls leaving the test process (confirm with --reporter=verbose — no timeout warnings).
  • Setting MOCK_API=false causes zero handler code to execute (verify by adding a console.log inside a handler and confirming it never prints).
  • No unhandled request warnings appear for your own API endpoints (third-party passthroughs are explicitly listed).
  • You can add a temporary server.use(http.get('/api/users/:id', () => HttpResponse.json({ error: 'Forbidden' }, { status: 403 }))) inside one test and it overrides the default handler for that test only, with the default restored after afterEach(() => server.resetHandlers()).

Once these signals are green, you are ready to build out advanced MSW handler patterns or configure response shaping techniques to add latency simulation and stateful responses.


Interception is a layer decision, not a tool decision

The recurring theme across this section is that the tool matters far less than the layer. Two teams using the same library can have completely different coverage depending on where it is installed and what traffic that point can see.

That is why the first question about any interception setup is not “which library” but “what does this point observe, and what passes it unseen”. A setup that answers the second question precisely will survive a change of tool; one that does not will produce a gap the moment a new transport, an iframe or a third-party client appears.

FAQ

Can I intercept requests from server-side rendering code with MSW?

MSW’s browser integration relies on a Service Worker and cannot intercept Node.js fetch or http.request calls directly. Use setupServer from msw/node for SSR and test environments. If you also need non-JS services (Go microservices, mobile clients) to use the same mock surface, route all traffic through a local proxy — see the local API gateway routing setup for a concrete approach.

How do I stop mock handlers from running in production?

Guard mock initialisation behind process.env.NODE_ENV === 'development' or a dedicated MOCK_API=true flag. In CI jobs that must hit real staging endpoints, set MOCK_API=false explicitly in the job’s env: block. Never bundle the MSW Service Worker or any WireMock container into a production image — use dynamic import() so the bundler can tree-shake the mock module entirely when the flag is false.

What is the correct matcher precedence when multiple handlers match the same URL?

MSW evaluates handlers in registration order — the first match wins. Register exact-path handlers before regex handlers, and regex handlers before wildcard catch-alls. For WireMock, explicit url equality beats urlPathMatching, which beats body matchers. Set a numeric priority on each WireMock mapping to make ordering explicit rather than relying on file load order.

How do I intercept multipart/form-data uploads in MSW?

Read the request body as FormData inside the resolver: const data = await request.formData(). From there you can assert field values or return a shaped mock response. Never call request.json() on multipart requests — it will throw a parse error because the body is not JSON-encoded.


← Back to API Mocking Fundamentals & Architecture