Writing Custom MSW Response Resolvers
Static fixture files break the moment a test exercises a mutation-heavy workflow: the second POST to /api/orders returns the same pre-baked payload as the first, and the UI diverges from production behaviour. Custom MSW response resolvers solve this by giving you a JavaScript function — not a JSON file — at the centre of every request interception pattern, letting you compute responses from live request data, accumulate state, and inject failures on demand.
Why Static Fixtures Fall Short Here
MSW 2.x changed the resolver signature from the old (req, res, ctx) triple to a single info object containing request, params, and cookies. Teams upgrading from MSW 1.x sometimes discover their handlers silently stop matching — the pattern still compiles but the destructuring picks up undefined values. This is the most common source of “resolver not triggering” bugs, and it is entirely a config-version gap, not a logic error.
A second gap appears when teams lean on proxy vs inline mocking strategies: inline mocks via MSW demand that the resolver carries all the business logic the proxy would have delegated to a real server. That logic — validation, state transitions, conditional branching — belongs in the resolver, not scattered across fixture directories.
The SVG below shows the resolver’s position in the full request lifecycle:
Solution — Step-by-Step Implementation
1. Define a dynamic payload resolver
Parse params for route segments and new URL(request.url).searchParams for query strings. Apply conditional logic before constructing the response. TypeScript generics enforce contract alignment between the resolver and the consuming component.
import { http, HttpResponse } from 'msw';
interface UserPayload {
id: string;
name: string;
status: 'active' | 'suspended';
createdAt?: string;
}
export const customUserResolver = http.get('/api/users/:id', ({ params, request }) => {
const userId = String(params.id);
const url = new URL(request.url);
const includeMetadata = url.searchParams.get('meta') === 'true';
const payload: UserPayload = {
id: userId,
name: 'Mock User',
status: 'active',
...(includeMetadata && { createdAt: new Date().toISOString() })
};
return HttpResponse.json(payload);
});
Key rules for this step:
- Return
HttpResponse(orundefinedto delegate). Returningnullor throwing unhandled errors breaks the interception chain. HttpResponse.json()automatically setsContent-Type: application/json— do not set it manually.- Access cookies via the
cookiesproperty on the info object, not viarequest.headers.get('Cookie').
2. Add module-scoped in-memory state
Declare a mutable registry outside the resolver closure. State then accumulates correctly across sequential POST/PUT/PATCH calls within the same test or dev session — exactly replicating session semantics without an external database. This underpins the kind of stateful scenario sequencing described in the schema-driven data generation section of this site.
import { http, HttpResponse, delay } from 'msw';
interface Order {
id: string;
items: unknown[];
status: 'pending' | 'confirmed' | 'cancelled';
createdAt: string;
}
// Module-scoped — persists for the lifetime of the worker or test server
const orderRegistry: Order[] = [];
export const statefulOrderResolver = http.post('/api/orders', async ({ request }) => {
const body = await request.json() as { items?: unknown[] };
await delay(1200); // Simulate realistic network round-trip time
if (!body.items || body.items.length === 0) {
return HttpResponse.json({ error: 'Cart is empty', code: 'EMPTY_CART' }, { status: 400 });
}
const newOrder: Order = {
id: crypto.randomUUID(),
items: body.items,
status: 'pending',
createdAt: new Date().toISOString()
};
orderRegistry.push(newOrder);
return HttpResponse.json(newOrder, { status: 201 });
});
export const getOrderResolver = http.get('/api/orders/:id', ({ params }) => {
const order = orderRegistry.find(o => o.id === params.id);
if (!order) {
return HttpResponse.json({ error: 'Order not found' }, { status: 404 });
}
return HttpResponse.json(order);
});
/** Call this in beforeEach / afterEach to prevent cross-test pollution */
export function resetOrderState(): void {
orderRegistry.length = 0;
}
3. Inject latency and error codes for resilience testing
QA engineers and platform teams use resolvers to validate frontend error boundaries and loading states without touching a real backend. This connects directly to mock lifecycle management — the same lifecycle reset principles that govern server teardown apply here at the resolver level.
import { http, HttpResponse, delay } from 'msw';
export const flakyCatalogueResolver = http.get('/api/catalogue', async ({ request }) => {
const url = new URL(request.url);
const scenario = url.searchParams.get('scenario');
if (scenario === 'timeout') {
await delay(30_000); // Force a client-side timeout
return HttpResponse.json({ error: 'Gateway timeout' }, { status: 504 });
}
if (scenario === 'rate-limited') {
return HttpResponse.json(
{ error: 'Too many requests', retryAfter: 60 },
{
status: 429,
headers: { 'Retry-After': '60' }
}
);
}
if (scenario === 'server-error') {
return HttpResponse.json({ error: 'Internal server error' }, { status: 500 });
}
// Happy path
await delay(200);
return HttpResponse.json({ items: [{ id: '1', name: 'Widget A' }] });
});
Wrap latency injection in an environment check to ensure it never reaches staging or production builds:
const BASE_DELAY = process.env.NODE_ENV === 'test' ? 0 : 200;
await delay(BASE_DELAY);
4. Register resolvers with conditional activation
Wire all resolvers into a single worker instance and guard activation behind an environment variable. For Vite projects:
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { customUserResolver, statefulOrderResolver, getOrderResolver, flakyCatalogueResolver } from './resolvers';
export const worker = setupWorker(
customUserResolver,
statefulOrderResolver,
getOrderResolver,
flakyCatalogueResolver
);
// src/main.ts
async function enableMocking(): Promise<void> {
if (import.meta.env.VITE_ENABLE_MOCKS !== 'true') return;
const { worker } = await import('./mocks/browser');
await worker.start({
onUnhandledRequest: 'warn', // Surface routing mismatches immediately
quiet: false
});
}
await enableMocking();
For Node.js test suites (Vitest or Jest):
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { customUserResolver, statefulOrderResolver, getOrderResolver } from './resolvers';
export const server = setupServer(
customUserResolver,
statefulOrderResolver,
getOrderResolver
);
// vitest.setup.ts
import { beforeAll, afterAll, afterEach } from 'vitest';
import { server } from './src/mocks/server';
import { resetOrderState } from './src/mocks/resolvers';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
server.resetHandlers();
resetOrderState(); // Clear stateful registry between tests
});
afterAll(() => server.close());
Verification
Open the browser DevTools Network panel after starting your dev server with VITE_ENABLE_MOCKS=true. Requests intercepted by MSW show a (ServiceWorker) label in the Initiator column — not an actual network roundtrip. For Node.js test runs, a passing test suite with onUnhandledRequest: 'error' is the definitive signal: any real network call would throw.
# Verify the Service Worker registered correctly
# Expected: "[MSW] Mocking enabled." in browser console
VITE_ENABLE_MOCKS=true npx vite
# For Node.js test verification
npx vitest run --reporter=verbose
Gotchas and Edge Cases
- HMR state duplication. Vite’s hot-module replacement re-executes module code on file save. Because the order registry is module-scoped, HMR can duplicate entries. Clear the store in a
module.hot?.acceptcallback or by refreshing the page after resolver edits. - Missing
awaitonrequest.json(). Forgettingawaitbeforerequest.json()returns a pendingPromise— your payload will be[object Promise]in the response. Always mark resolver functionsasyncwhen reading the request body. - CORS preflight mismatches. If the resolver returns a response but the browser still reports a CORS error, add an
http.optionshandler that returns200withAccess-Control-Allow-Origin: *headers. MSW does not auto-handleOPTIONSpreflight requests.
FAQ
What should a resolver return when it cannot match a request?
Return undefined to fall through to the next handler in the list, or — if no handler matches — to the real network (if the worker was started with onUnhandledRequest: 'bypass'). Never return null and never throw without catching: both break the interception chain silently and are the hardest class of MSW bugs to diagnose.
How do I reset in-memory state between test cases?
Export a named resetState() function from the resolver module and call it in afterEach. Do not rely on server.resetHandlers() alone — that restores the handler list but leaves your module-scoped stores intact.
Can I share the same resolver between browser and Node.js environments?
Yes. The resolver function itself is environment-agnostic. Only the bootstrapping call differs: setupWorker for the browser, setupServer for Node.js. Keep resolvers in a shared module and import them into both entry points.
Related
- Advanced MSW Handler Patterns — composing handlers, lifecycle hooks, and handler overrides
- Mock Service Worker Setup — initial MSW installation and Service Worker registration
- Request Interception Patterns — how the browser and Node.js interception layers work at the network level
- Schema-Driven Data Generation — generating typed fixture data that pairs with stateful resolvers
← Back to Advanced MSW Handler Patterns