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.


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/.

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.


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