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:

MSW resolver execution flow A flow diagram showing a browser fetch request passing through the Service Worker, hitting the MSW handler router, executing the resolver function, and returning an HttpResponse — bypassing the real network entirely. Browser fetch() Service Worker intercepts request Handler Router matches path+method Resolver fn returns HttpResponse synthesised response (real network never contacted)

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 (or undefined to delegate). Returning null or throwing unhandled errors breaks the interception chain.
  • HttpResponse.json() automatically sets Content-Type: application/json — do not set it manually.
  • Access cookies via the cookies property on the info object, not via request.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?.accept callback or by refreshing the page after resolver edits.
  • Missing await on request.json(). Forgetting await before request.json() returns a pending Promise — your payload will be [object Promise] in the response. Always mark resolver functions async when reading the request body.
  • CORS preflight mismatches. If the resolver returns a response but the browser still reports a CORS error, add an http.options handler that returns 200 with Access-Control-Allow-Origin: * headers. MSW does not auto-handle OPTIONS preflight 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.


← Back to Advanced MSW Handler Patterns