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