Skip to content

Fix: MSW (Mock Service Worker) Not Working — Handlers Not Intercepting, Browser Not Mocking, or v2 Migration Errors

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Mock Service Worker issues — browser vs Node setup, handler registration, worker start timing, passthrough requests, and common MSW v2 API changes from v1.

The Problem

MSW is set up but requests still go to the real server:

// handlers.ts
export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([{ id: 1, name: 'Alice' }]);
  }),
];

// tests — real fetch still fires despite MSW setup
const response = await fetch('/api/users');
// Returns real server data, not the mock

Or the browser worker doesn’t start:

// src/mswWorker.js
export const worker = setupWorker(...handlers);
// worker.start() called but network tab shows real requests

Or MSW v1 code breaks after upgrading to v2:

// v1 syntax — no longer valid in v2
import { rest } from 'msw';
rest.get('/api/users', (req, res, ctx) => {
  return res(ctx.json([{ id: 1 }]));
});
// TypeError: rest is not a function (or not exported)

Or some requests are mocked but others bypass MSW:

[MSW] Warning: intercepted a request without a matching request handler
GET https://cdn.example.com/api/config — bypassed

Why This Happens

MSW intercepts at different layers depending on the environment:

  • Browser — MSW registers a Service Worker that intercepts network requests. If worker.start() isn’t awaited before your app makes requests, those early requests miss the interceptor.
  • Node.js (tests) — MSW uses @mswjs/interceptors to patch the global fetch, XMLHttpRequest, and Node’s http module. If server.listen() isn’t called before tests run, no interception happens.
  • v1 → v2 breaking changes — MSW v2 replaced the rest and graphql namespaces with http and graphql, changed the handler signature from (req, res, ctx) to ({ request, params, cookies }), and changed the response format to standard Response/HttpResponse.
  • Unmatched requests — by default in v2, unmatched requests pass through to the real network. In v1, unmatched requests also passed through unless configured otherwise.

The interception mechanism is the source of every “MSW isn’t working” misunderstanding. In the browser, MSW does not run in your tab — it runs in a separately registered Service Worker process. Requests in your tab go through the Service Worker on their way to the network. If the worker isn’t registered, isn’t activated, or is registered at the wrong scope, your tab makes real requests and MSW has no visibility into them. In Node tests, MSW patches global fetch/XHR/http when server.listen() runs. If your test file imports something that grabs a reference to fetch before server.listen() executes — common in test setups that import the app entry — the patched fetch is bypassed and the original network call goes through.

The v1→v2 transition compounds the problem. In v1, the handler API was rest.get(path, (req, res, ctx) => res(ctx.json(...))). In v2, it’s http.get(path, () => HttpResponse.json(...)). A project that mixes the two compiles fine (TypeScript happily imports rest from v1’s typings if you have a stale @types package) but produces handlers that never match. The fix is to commit fully to one version: pin msw to either ^1.3 or ^2.0 across the entire dependency tree, delete node_modules, reinstall, and rewrite every handler.

Diagnostic Timeline

Tests pass locally but the assertion you’re trying to add against the mocked response always sees real data.

Minute 0–3. First wrong suspicion: “I need to rewrite the handlers.” Don’t touch the handlers yet. Add onUnhandledRequest: 'error' to server.listen({...}). If MSW is actually intercepting, every unmatched request crashes the test with a clear log. If you get no error and the real network still fires, MSW isn’t intercepting at all — the handlers are not the problem.

Minute 3–8. Check version mismatch. npm ls msw — if you see two versions (often 1.3.x from a transitive dependency plus 2.x.x in your package.json), that’s the root cause. The rest handlers from v1 and http handlers from v2 don’t share a registry, so handlers registered against v2 don’t apply to fetches intercepted by v1’s interceptors. Fix with an overrides entry in package.json to force a single version.

Minute 8–15. Confirm the test setup actually starts the server before your code. Add console.log('msw listening') immediately after server.listen() and console.log('app boot') at the top of your app entry. The order of those two logs tells you whether your imports are loading the app (and grabbing a fetch reference) before MSW patches the global. The fix is to move server.listen() into a setupFiles (Vitest) or setupFilesAfterEach (Jest) entry that runs before any test imports the app.

Minute 15–25. Browser scenario. First wrong suspicion: “regenerate the service worker file.” Maybe later. First, open DevTools → Application → Service Workers. If the worker shows “redundant” or “not registered,” your mockServiceWorker.js is being served from a 404 or a wrong content type. Check the network response for GET /mockServiceWorker.js — a 200 with application/javascript is required. Vite needs the file in public/, not src/. Next.js needs it in public/ too, and you may have to disable PWA service workers if they conflict.

Minute 25–35. Polyfill order. In Node 18+, fetch is global, but if you’re running tests on Node 16 or use undici as a polyfill, MSW v2 must be paired with the right interceptor. With vitest’s environment: 'jsdom', the jsdom fetch wins unless you explicitly use environment: 'happy-dom' or environment: 'node'. Switch environments and re-test.

Minute 35+. Last-resort cause: handler path mismatch. http.get('/api/users', ...) only matches that exact relative URL. If your fetch uses an absolute URL (fetch('http://localhost:3000/api/users')), the handler must use the absolute URL or use the wildcard pattern '*/api/users'. Add onUnhandledRequest: 'warn' and inspect the console — MSW logs every unmatched request with the exact URL it saw, which makes the mismatch obvious.

Fix 1: Set Up MSW Correctly for Node.js Tests

For Jest, Vitest, or other Node.js test runners:

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Alice', email: '[email protected]' },
      { id: 2, name: 'Bob', email: '[email protected]' },
    ]);
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: 3, ...body }, { status: 201 });
  }),

  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;
    if (id === '999') {
      return HttpResponse.json({ error: 'Not found' }, { status: 404 });
    }
    return HttpResponse.json({ id, name: 'Alice' });
  }),
];

// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// src/setupTests.ts (or vitest.setup.ts / jest.setup.ts)
import { server } from './mocks/server';

// Start server before all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));

// Reset handlers after each test (undo any runtime additions)
afterEach(() => server.resetHandlers());

// Clean up after all tests
afterAll(() => server.close());
// jest.config.ts / vitest.config.ts
export default {
  setupFilesAfterFramework: ['./src/setupTests.ts'],
  // OR for Vitest:
  test: {
    setupFiles: ['./src/setupTests.ts'],
  },
};

Fix 2: Set Up MSW for Browser Development

For development mode interception in the browser:

# 1. Generate the service worker file
npx msw init public/ --save
# Creates public/mockServiceWorker.js
# Adds "msw" to package.json under "browser" or saves config

# 2. Add mockServiceWorker.js to your static files
# Ensure it's served at the root URL: http://localhost:3000/mockServiceWorker.js
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);

// src/main.tsx (or index.tsx) — start worker before rendering
async function enableMocking() {
  if (process.env.NODE_ENV !== 'development') return;

  const { worker } = await import('./mocks/browser');

  // Start the worker — MUST be awaited
  return worker.start({
    onUnhandledRequest: 'warn',  // Warn for requests without a handler
    serviceWorker: {
      url: '/mockServiceWorker.js',  // Path to the service worker
    },
  });
}

enableMocking().then(() => {
  ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
});

Vite — ensure service worker is served correctly:

// vite.config.ts
export default defineConfig({
  // If using a base URL, configure the service worker path
  server: {
    // The worker file must be at the root
  },
});

Note: The Service Worker must be served from the same origin as your app. If your app runs on http://localhost:3000, the worker must be at http://localhost:3000/mockServiceWorker.js.

Fix 3: Migrate from MSW v1 to v2

MSW v2 changed the entire handler API:

// v1 — OLD syntax
import { rest } from 'msw';

export const handlers = [
  rest.get('/api/users', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json([{ id: 1, name: 'Alice' }])
    );
  }),

  rest.post('/api/users', async (req, res, ctx) => {
    const body = await req.json();
    return res(
      ctx.status(201),
      ctx.json({ id: 2, ...body })
    );
  }),

  rest.get('/api/users/:id', (req, res, ctx) => {
    const { id } = req.params;
    return res(ctx.json({ id, name: 'Alice' }));
  }),
];

// v2 — NEW syntax
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([{ id: 1, name: 'Alice' }]);
    // Status 200 is the default
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json();  // Standard Request API
    return HttpResponse.json({ id: 2, ...body }, { status: 201 });
  }),

  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;  // params is an object
    return HttpResponse.json({ id, name: 'Alice' });
  }),
];

v2 response formats:

import { http, HttpResponse } from 'msw';

// JSON response
http.get('/api/data', () => HttpResponse.json({ key: 'value' }))

// Text response
http.get('/api/text', () => HttpResponse.text('Hello world'))

// Error response
http.get('/api/fail', () => HttpResponse.json(
  { error: 'Not found' },
  { status: 404 }
))

// With custom headers
http.get('/api/file', () => new HttpResponse(blob, {
  headers: { 'Content-Type': 'application/pdf' }
}))

// Network error (connection refused)
http.get('/api/down', () => HttpResponse.networkError('Service unavailable'))

// Delay response
http.get('/api/slow', async () => {
  await new Promise(r => setTimeout(r, 2000));
  return HttpResponse.json({ data: 'slow response' });
})

Fix 4: Override Handlers Per Test

Add or replace handlers for specific tests without affecting other tests:

import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';

describe('UserList', () => {
  test('shows users', async () => {
    // Default handler from handlers.ts applies
    render(<UserList />);
    await screen.findByText('Alice');
  });

  test('shows error state', async () => {
    // Override for this test only
    server.use(
      http.get('/api/users', () => {
        return HttpResponse.json(
          { error: 'Internal server error' },
          { status: 500 }
        );
      })
    );

    render(<UserList />);
    await screen.findByText('Failed to load users');
    // server.resetHandlers() in afterEach restores default handlers
  });

  test('shows empty state', async () => {
    server.use(
      http.get('/api/users', () => {
        return HttpResponse.json([]);  // Empty array
      })
    );

    render(<UserList />);
    await screen.findByText('No users found');
  });
});

One-time handlers (respond once then fall through):

server.use(
  http.get('/api/users', () => {
    return HttpResponse.json([{ id: 1 }]);
  }, { once: true })  // Removes itself after first match
);

Fix 5: Handle GraphQL, Cookies, and Headers

import { http, graphql, HttpResponse } from 'msw';

// GraphQL handlers
const graphqlHandlers = [
  graphql.query('GetUser', ({ variables }) => {
    const { id } = variables;
    return HttpResponse.json({
      data: { user: { id, name: 'Alice', email: '[email protected]' } },
    });
  }),

  graphql.mutation('CreateUser', ({ variables }) => {
    return HttpResponse.json({
      data: { createUser: { id: '2', ...variables.input } },
    });
  }),

  // With errors
  graphql.query('GetProtectedData', () => {
    return HttpResponse.json({
      errors: [{ message: 'Unauthorized', extensions: { code: 'UNAUTHENTICATED' } }],
    });
  }),
];

// Access request headers
http.get('/api/protected', ({ request }) => {
  const authHeader = request.headers.get('Authorization');

  if (!authHeader?.startsWith('Bearer ')) {
    return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  return HttpResponse.json({ data: 'secret' });
})

// Access cookies
http.get('/api/session', ({ cookies }) => {
  const sessionId = cookies.session_id;
  if (!sessionId) {
    return HttpResponse.json({ error: 'No session' }, { status: 401 });
  }
  return HttpResponse.json({ userId: '1' });
})

// Set response cookies
http.post('/api/login', () => {
  return HttpResponse.json({ success: true }, {
    headers: {
      'Set-Cookie': 'session_id=abc123; HttpOnly; Path=/',
    },
  });
})

Fix 6: Passthrough and Unhandled Request Behavior

// server.ts — configure unhandled request behavior
export const server = setupServer(...handlers);

server.listen({
  onUnhandledRequest: 'warn',  // Log warning (default)
  // onUnhandledRequest: 'error',  // Fail the test
  // onUnhandledRequest: 'bypass',  // Silently pass through
  // onUnhandledRequest: (request) => {
  //   // Custom handling
  //   if (request.url.includes('analytics')) return;  // Ignore analytics
  //   console.warn('Unhandled:', request.method, request.url);
  // },
});

// Passthrough specific requests
import { passthrough } from 'msw';

export const handlers = [
  // Match all API calls
  http.get('/api/*', ({ request }) => {
    // Pass through requests to external CDN
    if (request.url.includes('cdn.example.com')) {
      return passthrough();
    }
    // Handle locally
    return HttpResponse.json([]);
  }),

  // Explicit passthrough handler
  http.get('https://cdn.example.com/*', () => passthrough());
];

Still Not Working?

Service Worker not registering in browser — open DevTools → Application → Service Workers. If the worker isn’t listed, check: (1) mockServiceWorker.js exists at the correct URL, (2) you’re on HTTPS or localhost (Service Workers require a secure context), (3) worker.start() is awaited before your app renders. Check the browser console for registration errors.

MSW intercepts in tests but the response is wrong — verify the handler URL matches exactly. http.get('/api/users', ...) matches relative URL /api/users. If your fetch uses a full URL (fetch('http://localhost:3000/api/users')), the handler must also use the full URL or a URL with a wildcard: http.get('http://localhost:3000/api/users', ...). Use '**/api/users' for glob matching in Playwright but not in MSW — MSW uses exact URL matching or new URL() patterns.

server.resetHandlers() not workingresetHandlers() removes handlers added via server.use() at runtime, restoring to the initial handlers passed to setupServer(). If you’re modifying the original handlers array directly, resetHandlers() won’t help. Always add test-specific handlers via server.use(), never mutate the original array.

Two versions of msw resolved in the lock file — common when one of your dependencies pins MSW v1 transitively while you’ve upgraded the top-level to v2. Run npm ls msw (or pnpm why msw). If two versions appear, add an overrides entry to package.json to force a single version, delete node_modules and package-lock.json, then reinstall. The two-versions bug manifests as “some handlers work, others don’t” because each interceptor instance only sees handlers registered against its own runtime.

Fetch reference captured before server.listen() — if your test file imports the SDK client (or app entry) at the top, and the SDK saves globalThis.fetch to a local variable in its module init, that captured reference points to the unpatched fetch. Move import { server } and server.listen() to the very top of your setup file (above any imports that touch the SDK), or call the SDK via await import(...) lazily inside each test.

Node 16 with native fetch missing the interceptor — Node 16 doesn’t ship fetch globally, so MSW v2 expects you to provide a polyfill via undici or upgrade to Node 18+. If globalThis.fetch is undefined when server.listen() runs, MSW prints a warning. Bumping to Node 18+ (or 20+) removes the friction entirely.

For related testing issues, see Fix: Jest Mock Not Working, Fix: Playwright Not Working, Fix: Cypress Not Working, and Fix: TanStack Query Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles