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
fetchavailable without polyfill) -
msw2.x installed (npm install msw --save-dev) or a WireMock-compatible proxy running locally -
MOCK_APIenvironment variable defined in.env.local(set totruefor local,falsefor 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:
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:
- Exact path (
/api/users/me) — no ambiguity, always first. - Path with named parameters (
/api/users/:id) — before regex. - Regex paths (
/\/api\/orders\/\d+/) — after named params. - Wildcard catch-all (
/api/*) — last; use sparingly. http.allpassthrough — 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.jslisted 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 see200 (from ServiceWorker), not200 (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 startedand[MSW] Server closedat 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) ifonUnhandledRequest: '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
fetchorXHRcall 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=falsecauses zero handler code to execute (verify by adding aconsole.loginside 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 afterafterEach(() => 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.
Related
- How to Intercept Fetch Requests in React — step-by-step implementation inside a React component tree, including memory-safe teardown
- Response Shaping Techniques — once interception is working, add latency, error injection, and stateful sequences
- Proxy vs Inline Mocking Strategies — decision guide for choosing between client-side hooking and network-level proxying
- Mock Lifecycle Management — server startup, handler reset, and graceful shutdown across test suites
- Advanced MSW Handler Patterns — conditional responses, stateful counters, and handler composition
← Back to API Mocking Fundamentals & Architecture