@mswjs/interceptors
@mswjs/interceptors is the network interception engine underneath Mock Service Worker. Instead of monkey-patching http.request or a specific client like axios, it hooks Node at the TCP and TLS socket handle level and stubs TCPWrap and TLSWrap until you decide whether to claim a connection or let it through. Higher-level interceptors sit on top of that socket layer and parse the packets back into requests, so a single HttpRequestInterceptor sees traffic from node:http, the global fetch, direct undici calls, axios, got, and node-fetch alike. Every intercepted request is handed to you as a standard Fetch API Request, and you reply with a standard Response through a controller object. There are also narrower interceptors for http.ClientRequest, XMLHttpRequest, fetch, raw sockets, and WebSocket connections, plus a BatchInterceptor to run several at once.
This is infrastructure for tool authors, and it is very good at that job: socket-level interception means axios, got, undici, and native fetch all get caught by one listener. If you are writing tests rather than writing a testing library, install msw instead and let it depend on this for you.
Use it if
- You are building your own mocking, recording, or proxying tool and want the interception algorithm handed to you instead of writing another http.request patch
- You need to observe or mock traffic from clients you do not control, including direct undici usage and native fetch, which client-specific patches miss because the request never touches http.ClientRequest
- You want the intercepted request and your mocked reply expressed as Fetch API Request and Response objects rather than a library-specific request shape
- You need WebSocket interception with both sides available: the client connection and the original server connection, so you can forward, drop, or rewrite individual messages
- You just want to mock HTTP in tests. The README says it plainly: this is not an API mocking library, and if you are unsure whether you need it you probably do not. Use msw or nock and get request matching, handler priority, and lifecycle helpers for free
- You want routing: there is no URL matching layer at all. One global request listener fires for every request and you write the if-else tree yourself, including the rule that each request can be responded to exactly once
- You are pinned to an older Node. The package declares engines node >=22, and the socket-level approach depends on internals that shift between Node releases, so running it outside supported versions is not a supported configuration
- You need semver comfort: it is still 0.x after years of development, so minor bumps carry breaking changes and there is a separate backport dist-tag on 0.17 for people who could not follow
- You need to rewrite request bodies before they hit the server. Request representations are readonly; only headers can be mutated in the listener, and the README states the library is not meant to be a full-scale proxy
Setup reality
npm install @mswjs/interceptors is small and dependency-light (debug, outvariant, rettime, is-node-process, @open-draft/until) with no native build, but Node 22 or newer is required by the engines field. Nothing is intercepted until you call interceptor.apply(), and nothing is cleaned up until you call interceptor.dispose(), so in a test suite you own that lifecycle by hand: apply in beforeAll, removeAllListeners between tests, dispose in afterAll, or the patched globals leak into the next file. Imports are per-interceptor subpaths (@mswjs/interceptors/fetch, /ClientRequest, /XMLHttpRequest, /WebSocket, /http, /net), and some of them resolve to different /node or /web builds depending on the environment, which trips up bundlers with unusual condition handling. The biggest behavioral surprise is timing: a request must be answered within the same tick as the listener, so setTimeout does not work; make the listener async and await instead. Unhandled exceptions in your listener are quietly turned into 500 responses unless you rethrow them from an unhandledException listener.
Patterns
Catch every HTTP client with one interceptorintercept-all-http
import { HttpRequestInterceptor } from '@mswjs/interceptors/http'
const interceptor = new HttpRequestInterceptor()
interceptor.apply()
interceptor.on('request', ({ request, requestId }) => {
console.log(request.method, request.url)
})Because this hooks the socket layer, it sees node:http, global fetch, direct undici, axios, and got without any per-client adapters. The tradeoff is that initiator is just a net.Socket unless you also apply the matching client-level interceptor.
Reply with a mocked responsemock-a-response
import { FetchInterceptor } from '@mswjs/interceptors/fetch'
const interceptor = new FetchInterceptor()
interceptor.apply()
interceptor.on('request', ({ request, controller }) => {
if (new URL(request.url).pathname === '/api/user') {
controller.respondWith(
Response.json({ id: 1, name: 'Ada' }, { status: 200 })
)
}
})Not calling respondWith lets the request hit the real network. A request can only be answered once, and there is no matching layer, so the URL check is your job.
Add latency without breaking the tick ruledelay-a-response
import { setTimeout } from 'node:timers/promises'
interceptor.on('request', async ({ controller }) => {
await setTimeout(500)
controller.respondWith(new Response(null, { status: 503 }))
})Responses must be produced in the same tick as the listener, so a callback-style setTimeout silently misses the window. An async listener that awaits works because the interceptor waits on the returned promise.
Inspect the request body safelyread-request-body
interceptor.on('request', async ({ request, controller }) => {
const body = await request.clone().json()
if (body.action === 'delete') {
controller.respondWith(new Response(null, { status: 403 }))
}
})Clone before reading. Reading the body off the original request consumes the stream and the real request then fails or sends nothing.
Add a header to outgoing requestsmodify-request-headers
interceptor.on('request', ({ request }) => {
request.headers.set('x-trace-id', crypto.randomUUID())
})Headers are the only mutable part of an intercepted request. Method, URL, and body are readonly by design, so this is not a substitute for a rewriting proxy.
Simulate a network failureerror-a-request
interceptor.on('request', ({ controller }) => {
// generic network error
controller.respondWith(Response.error())
})
// or with a specific reason
interceptor.on('request', ({ controller }) => {
controller.errorWith(new Error('ECONNRESET simulated'))
})errorWith carries your message, but clients differ: http.ClientRequest surfaces it, while fetch throws a generic TypeError with the original error tucked into cause.
Stop exceptions turning into 500 responseshandle-listener-exceptions
interceptor.on('unhandledException', ({ error, request }) => {
console.error('mock handler blew up for', request.url)
throw error
})By default a thrown error in the request listener becomes a 500 response, which hides bugs in your own mock code. Rethrowing inside unhandledException makes it a real failure again.
Apply the Node preset in one gobatch-interceptors
import { BatchInterceptor } from '@mswjs/interceptors'
import nodeInterceptors from '@mswjs/interceptors/presets/node'
const interceptor = new BatchInterceptor({
name: 'test-interceptor',
interceptors: nodeInterceptors,
})
interceptor.on('request', ({ request }) => console.log(request.url))
interceptor.apply()The node preset is ClientRequestInterceptor plus XMLHttpRequestInterceptor plus FetchInterceptor; a browser preset drops the first. Applying client-level interceptors alongside HttpRequestInterceptor is also what makes the initiator property useful.
Wire apply and dispose into a test suitetest-lifecycle
import { beforeAll, afterEach, afterAll } from 'vitest'
import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest'
const interceptor = new ClientRequestInterceptor()
beforeAll(() => interceptor.apply())
afterEach(() => interceptor.removeAllListeners())
afterAll(() => interceptor.dispose())Nothing is automatic here. Skip dispose and the patched globals survive into other test files; skip removeAllListeners and listeners from a previous test keep answering requests.
Log responses and tell mocks from real trafficobserve-responses
interceptor.on('response', ({ response, responseType, request }) => {
console.log(responseType, response.status, request.url)
// responseType is 'mock' or 'original'
})This is the hook for recording fixtures or asserting that a request actually reached the network. responseType is the only reliable way to tell whether your handler answered it.
Intercept a WebSocket connectionintercept-websocket
import { WebSocketInterceptor } from '@mswjs/interceptors/WebSocket'
const interceptor = new WebSocketInterceptor()
interceptor.apply()
interceptor.on('connection', ({ client, server }) => {
client.addEventListener('message', (event) => {
if (event.data === 'ping') {
event.preventDefault()
client.send('pong')
}
})
})The intercepted connection is not opened at all until you call server.connect(). Once connected, client and server messages are forwarded both ways by default and preventDefault is how you stop one.
Claim a raw TCP connectionsocket-level-claim
import { SocketInterceptor } from '@mswjs/interceptors/net'
const interceptor = new SocketInterceptor()
interceptor.apply()
interceptor.on('connection', ({ socket, connectionOptions, controller }) => {
if (connectionOptions.host === 'db.internal') {
controller.claim()
socket.on('data', (chunk) => socket.write(chunk))
} else {
controller.passthrough()
}
})The connection stays pending until you call claim() or passthrough(), so forgetting the else branch hangs unrelated traffic. The exposed socket is mirrored: it emits data when the client writes, and writing to it delivers data to the client.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| msw | npm | You want the thing built on top of this: request handlers, URL matching, and one set of mocks shared by browser and Node tests |
| nock | npm | You are mocking Node HTTP in tests and prefer a chainable scope API with strict assertions about which requests were made |
| undici | npm | All your traffic goes through fetch or undici and MockAgent covers you without patching anything global |
| fetch-mock | npm | You only need to fake the global fetch and want matching plus call inspection in one small package |