If you're building a React dashboard or any frontend that talks to APIs, you've probably reached this inflection point: you need to mock APIs in your tests, but you also want realistic API calls during development. The old solution was duplication: Vitest with vi.mock() mocking your fetch calls in tests, and a separate mock server (json-server, Mirage, or a custom fixture server) for dev mode. That sucks. You write the same mock twice, they drift, and your tests don't catch the real API contract changing. MSW (Mock Service Worker) fixes this by intercepting HTTP at the service worker level — the same mock handlers work in the browser (dev mode), Node (tests), and CI. One source of truth. Aidxn ships MSW on every Velocity9 dashboard, every test suite, and every integration with Supabase or third-party APIs. Real payoff: your Playwright tests exercise the exact same mock API as your dev server uses, so what you see in the browser during development is what the tests validate. No surprises in production.
The Problem: API Mocking Sucks (Everywhere)
Dev Mode: Unrealistic Test Data
You start a dev server with a real API (localhost:3000 hits your Supabase project or staging backend). Cool for the first sprint. But then the API goes down. Or you want to test error states (500, timeout, rate limit). Or you want to test offline behavior. Suddenly you're spinning up a local Postgres instance or fighting network latency that doesn't exist in the real world. The only practical solution is to swap the API endpoint to a mock server when you want predictability. Some teams use json-server (20 lines, runs a REST API from a JSON file). Others write a simple Express server. Both solutions feel brittle: the mock server lives in your codebase, it's not versioned with your tests, and if your API schema changes, you have to remember to update both the backend AND the mock data file.
Testing: Duplicate Mocks, Drift Risk
In your test suite, you're mocking the same API calls with Vitest's vi.mock(). You write test fixtures for user login, product list, checkout flow — the same data you also modeled in your dev-mode mock server. Now when your backend changes the user schema (adds a tier field, renames name → fullName), you have to update the mock in two places. You forget one, a test passes, and production breaks. The mock schema in your tests diverges from your dev server. Coverage becomes illusion.
Playwright Tests: Mocks Don't Run
When you run Playwright tests, you're spinning up a real browser and hitting real network endpoints. You can't use Vitest's vi.mock() (that's for Node tests). So you either point Playwright at a staging server (slow, flaky, shares test data with other teams), or you spin up a parallel mock server in your test setup and route all traffic to it. Both are painful. The mock server setup is duplicated from your dev-mode server. If you want to test different API responses (success, error, timeout), you need a way to configure the mock on the fly — maybe via query params or a separate control endpoint. That's complexity you shouldn't have to build.
What MSW Is: Interception at the Service Worker Layer
MSW (Mock Service Worker) is a JavaScript library that intercepts HTTP requests at the service worker level. When you register MSW in your app (one line in your layout or test setup), it registers a service worker that sits between your fetch/axios calls and the actual network. Every HTTP request goes through MSW first. If MSW has a handler for that request, it responds with mock data. If not, the request passes through to the real network.
The genius: the same handler code runs everywhere. Write a handler that says "GET /api/users/:id returns a user object". That handler works in your browser during dev mode, in your Vitest unit tests, and in your Playwright E2E tests. One schema, one source of truth, zero duplication.
How It Works (30 Seconds)
import { http, HttpResponse } from 'msw';
const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Alice',
email: 'alice@example.com',
});
}),
];
// In dev mode: register service worker
// In tests: use setupServer(handlers)
// In Playwright: inject handlers via context
That handler intercepts every GET request to /api/users/:id, regardless of whether it's called from React, a test, or a Playwright script. The response is identical.
Three Patterns: Dev Mode, Vitest Unit Tests, Playwright Integration
Pattern 1: Dev Mode (Service Worker Registration)
In your browser, MSW registers a service worker once on app boot. Every subsequent fetch goes through MSW. Set up is two files:
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
]);
}),
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 }) => {
return HttpResponse.json({
id: params.id,
name: 'Alice',
});
}),
];
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
// src/main.tsx or your app entry point
import { worker } from './mocks/browser';
if (process.env.NODE_ENV === 'development') {
worker.listen();
}
One line in your root component or main entry. Now every fetch during dev mode goes through MSW. Swap handlers, change response data, test error states — all without touching the backend or spinning up a mock server.
Pattern 2: Vitest Unit Tests (setupServer)
In tests, you don't want a service worker (because you're in Node, not a browser). Use setupServer instead:
// vitest.setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './src/mocks/handlers';
export const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// myComponent.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MyUserComponent } from './MyUserComponent';
describe('MyUserComponent', () => {
it('fetches and displays users', async () => {
render(
MSW intercepts the fetch inside your component test. No vi.mock() cluttering your test file. The same handler that runs in dev mode handles the request. If your API contract changes, one update to handlers.ts fixes all tests automatically.
Pattern 3: Playwright Integration (Dynamic Handler Override)
For Playwright E2E tests, you want the service worker to run in the real browser context, but you might want to override specific handlers per test. MSW provides a server.use() method to layer additional handlers on top of the defaults:
// e2e/user-flow.test.ts
import { test, expect } from '@playwright/test';
import { http, HttpResponse } from 'msw';
test('handles API errors gracefully', async ({ page, context }) => {
// Inject MSW worker into the page context
await context.addInitScript(async () => {
const { setupWorker, http, HttpResponse } = await import('msw');
const worker = setupWorker(
http.get('/api/users/:id', () => {
return HttpResponse.json(
{ message: 'Server error' },
{ status: 500 }
);
})
);
await worker.listen();
});
await page.goto('http://localhost:5173');
await expect(page.locator('[data-testid="error-message"]')).toContainText('Server error');
});
Playwright injects the MSW worker into the browser context before loading the page. The browser sees the mocked API response, and your test validates error handling without hitting a real backend.
Real Numbers: Aidxn's Velocity9 Dashboard Setup
Aidxn's Velocity9 dashboard ships with 18 API endpoints (user auth, portfolio positions, order book, price feeds, notifications). Before MSW, we had: dev mode hitting Supabase (sometimes fails), 18 separate Vitest mock files (duplicated schemas), and Playwright tests spinning up a parallel mock server (slow, fragile). With MSW: one handlers.ts file (250 lines), same handlers run everywhere, Vitest suite passes in 2 seconds (zero mock setup overhead), Playwright tests run 30% faster (no parallel server). A new engineer on the team can enable dev mode, see realistic data without backend dependencies, and iterate 10x faster. Add one line to your test suite setup, and mock APIs magically work. Coverage of error states (timeouts, 500s, rate limits, offline) went from 40% to 95%. That's not just faster dev — that's better reliability.
Six FAQs
Does MSW work with async/await and streaming responses?
Yes. MSW handles async request bodies (await request.json()) and streaming responses. For streaming (like an SSE price feed), use HttpResponse.stream(). Example: return HttpResponse.stream(async (controller) => { controller.enqueue(new TextEncoder().encode('data: ...\n')); }). Works identically in dev and tests.
Can I use MSW with GraphQL?
Yes. MSW has a GraphQL handler: graphql.query(), graphql.mutation(). Define your resolvers once, and MSW handles the GraphQL layer transparently. Same benefit: one source of truth across dev, tests, Playwright.
What if I want real API calls in dev but mocked in tests?
Wrap the MSW initialization in an environment check: if (process.env.NODE_ENV === 'test') { worker.listen() }. In dev, calls hit the real backend; in tests, MSW intercepts. This is useful when you're working against a real staging API and only want to mock specific flows in tests.
Does MSW slow down tests?
No — it's faster than traditional mocking. Vitest with MSW runs 10-15% faster than Vitest with vi.mock() because you're not importing real modules; you're just intercepting network calls. Cold startup is identical.
What about authenticated requests? How do I mock tokens?
MSW has access to request headers. Your handler can inspect Authorization: Bearer xyz, validate the token (or fake it), and return accordingly: `http.get('/api/users', ({ request }) => { const auth = request.headers.get('Authorization'); if (!auth) return HttpResponse.json({}, { status: 401 }); ... })`. For Playwright, the browser session persists, so if you log in once, subsequent requests include the auth header automatically.
Is MSW production-ready?
Yes. MSW is stable, open-source, actively maintained, and used by major projects (Storybook, Remix, Next.js examples). The only caution: service workers have browser support (all modern browsers, IE11 does not). For IE11 support, you'd need a fallback mock server. But it's 2026 — don't use IE11.
The Bottom Line
MSW kills API mocking duplication. Write one handler, use it everywhere: dev mode, unit tests, E2E tests. Your mock schema lives in one place and evolves with your API. Developers see realistic data without backend setup. Tests are faster, more reliable, and easier to maintain. Error-state coverage goes from "nice to have" to standard. Aidxn uses MSW on every new project with an API layer. If you're still duplicating mocks across dev and test setups, try MSW for a week. You won't go back.
Ready to ship faster? Check out our test architecture consulting, or pair MSW with Playwright and Vitest for a complete testing story in our guide on Vitest for unit testing.