Choosing Between Proxy and Service Worker Mocks
For browser development you can intercept a request in two very different places: at the network boundary with a proxy, or inside the page with a Service Worker driven by MSW. Picking wrong means either a test that never exercises real network behaviour or an over-engineered proxy layer for mocks a Service Worker would have handled in a line of config. This page draws the line precisely.
Context: what each layer can and cannot see
The two approaches sit on opposite sides of the browser’s network boundary, and that placement determines what they can observe.
A Service Worker mock — the mechanism behind Mock Service Worker (MSW) setup — registers a worker that the browser consults for outgoing fetch and XHR calls originating from your page. It resolves the request from an in-browser handler and returns a Response object. Crucially, it operates within the page’s own origin context: no TCP connection is opened, no DNS lookup happens, and no cross-origin pre-flight is sent to a server. It sees the request as your application constructed it and answers before the network is ever touched. This is the browser-side expression of the request interception patterns family.
A network proxy mock — a dev-server proxy (Vite, webpack) or a standalone reverse proxy such as those covered under local API gateway routing — sits at the transport boundary. The browser makes a genuine connection to the proxy’s address; the proxy inspects the real HTTP request, including headers the browser added, and forwards it to a mock backend or a live upstream. Because a real connection is made, everything the network actually does happens: cross-origin pre-flight, TLS termination, redirects, and header rewriting are all in play.
The one-line summary: a Service Worker sees the request your code made; a proxy sees the request the network carries. If the difference between those two matters to your test, it decides the choice for you. This is the browser-specific slice of the wider proxy vs inline mocking strategies comparison.
Decision steps
Work through these in order and stop at the first that resolves.
1. Does the test need to exercise real network behaviour?
If you must prove that a cross-origin OPTIONS pre-flight succeeds, that a redirect chain is followed, that a TLS certificate is trusted, or that a specific upstream header survives — the request has to actually traverse the network. Choose a proxy. A Service Worker short-circuits before any of that happens.
2. Do all the mocked calls come from the browser’s fetch/XHR stack?
A Service Worker can only intercept requests the page makes through the standard web request APIs. If some traffic comes from a plugin, an embedded iframe on another origin, or a non-browser client sharing the environment, a proxy is the only layer that catches all of it.
3. Do you want one handler definition shared across browser and Node tests?
If the priority is a single source of truth that runs identically in Playwright (browser) and Vitest (Node), the Service Worker path wins: MSW’s setupWorker and setupServer consume the same handler array. A proxy is a separate configuration you would maintain in parallel.
4. Is per-request latency and setup overhead a concern? A Service Worker resolves in-process with no socket, so it is the lighter option for large browser suites. A proxy adds a loopback round-trip and a process to manage. If neither realism nor cross-runtime reach is required, default to the Service Worker.
If steps 1 and 2 both point away from the proxy, use a Service Worker mock. If either one requires real network behaviour or non-fetch traffic, use a proxy.
A small example of each
Service Worker interception with MSW
Register the worker and let it answer browser fetch calls before the network. The handlers are the same ones a Node test could reuse:
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('https://api.example.com/profile', () =>
HttpResponse.json({ id: 'u_42', name: 'Ada Lovelace', plan: 'pro' })
),
];
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
// src/main.ts — start the worker only in development
async function enableMocking() {
if (import.meta.env.MODE !== 'development') return;
const { worker } = await import('./mocks/browser');
await worker.start({ onUnhandledRequest: 'warn' });
}
enableMocking().then(() => {
// Boot the app after the Service Worker is active.
import('./app');
});
No proxy, no backend process — the request to https://api.example.com/profile is resolved inside the browser. The cross-origin call never leaves the page, which is exactly why it cannot validate CORS.
Network-proxy interception with the Vite dev server
Here the browser makes a real request to the dev server, which forwards /api to a mock backend on another port. Real headers and a real connection are involved:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:4010',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
configure(proxy) {
proxy.on('proxyReq', (proxyReq) => {
// A real header the mock backend will actually receive.
proxyReq.setHeader('x-forwarded-by', 'vite-dev-proxy');
});
},
},
},
},
});
# Start the mock backend the proxy forwards to (Prism running an OpenAPI spec)
npx @stoplight/prism-cli mock ./openapi.yaml --port 4010
Now a browser call to /api/profile opens a genuine connection to the Vite server, which rewrites and forwards it to Prism on port 4010. Because a real request is made, you can assert on forwarded headers and observe the true network round-trip that the Service Worker approach elides.
Verification
Confirm each interceptor is catching traffic at the layer you expect:
# Service Worker: the app made no real network call — the response came from the worker.
# In the browser devtools Network panel, the request row is served "(from ServiceWorker)".
# Programmatically, assert the mocked body in a Playwright test:
npx playwright test --grep "profile is served by the service worker"
# Proxy: a real request reached the mock backend. Confirm the forwarded header arrived.
curl -s -H "Accept: application/json" http://localhost:5173/api/profile -D - -o /dev/null \
| grep -i "content-type"
# Expected: content-type: application/json (served via the proxy from Prism)
The distinguishing signal: with the Service Worker, the browser Network panel marks the response as served from the Service Worker and no request reaches port 4010; with the proxy, the request genuinely arrives at the backend and carries the x-forwarded-by header.
Gotchas and edge cases
- A Service Worker will not validate CORS, and that is by design. Teams sometimes discover in staging that a cross-origin call fails pre-flight even though every local test passed — because the Service Worker answered before the browser ever sent an
OPTIONS. If CORS correctness is part of the contract you are testing, that flow must go through a proxy where a real cross-origin request is made. - The Service Worker must be active before the app makes its first call. Starting the worker and booting the app in the wrong order lets the first request escape to the network. Always
await worker.start()before importing the code that fetches, as the example does. - A dev-server proxy can swallow WebSocket upgrades. If the same origin serves both mocked REST and a live WebSocket, an over-broad proxy rule may try to forward the upgrade to the REST mock backend, which does not speak the protocol. Scope the proxy path narrowly, or add an explicit bypass for
upgrade: websocketrequests so long-lived connections pass through untouched.
FAQ
Can a Service Worker mock validate CORS pre-flight?
No. A Service Worker intercepts the request after the page has already decided the request is same-origin-safe from its perspective, so the browser’s cross-origin pre-flight (OPTIONS) machinery is not exercised end-to-end. If a test must prove that Access-Control-Allow-Origin is correct, route through a network proxy where a real cross-origin request is made.
Do Service Worker mocks work in Node-based unit tests?
The Service Worker itself is browser-only, but MSW ships a Node interceptor (setupServer) that reuses the exact same handlers without a Service Worker. So the handler definitions are portable: the worker runs them in the browser and setupServer runs them in Node, while a network proxy is a separate mechanism used mainly for browser and cross-runtime traffic.
Related
- Proxy vs Inline Mocking Strategies — the architectural trade-offs between transport-layer and runtime-layer interception
- Request Interception Patterns — how browser and Node interception points differ for the same call
- Local API Gateway Routing — building the network-proxy layer when a Service Worker cannot reach the traffic
← Back to Mocking Tool Selection