The Familiar Story of Every Frontend Developer
You’re building a new feature, but the backend API isn’t ready yet — or it’s broken on the dev server. The result: you sit and wait, or you scatter if (isDev) return fakeData all over the codebase and forget to remove it before deploying. Sound familiar?
I’ve been in that situation more times than I can count. My team once had a sprint where the frontend finished 3 days early but couldn’t test anything because the APIs weren’t ready. Three days completely wasted. That’s when I started looking for a proper solution — and Mock Service Worker (MSW) was the answer.
What MSW Is and Why It’s Different from Conventional Mocking
MSW is a JavaScript library that intercepts HTTP requests at the network level — not at the code level. That sounds simple, but it’s the key distinction.
Traditional API mocking approaches usually do one of two things:
- Mock directly in code:
jest.mock('./api')— only works in tests, not during real development - Spin up a fake server (json-server, Express mock) — requires extra setup, configuration, and ongoing maintenance
MSW takes a completely different approach. It installs a Service Worker in the browser — or uses a Node.js interceptor in test environments. This worker sits between your app and the real network, catching each request and returning a mocked response based on the logic you define.
Your app doesn’t know it’s receiving fake data. It calls fetch() normally and gets a normal response. More importantly: the same set of handlers works in both the dev environment and your test suite — no need to write things twice.
Installing and Configuring MSW
1. Install the library
npm install msw --save-dev
2. Define handlers
Handlers are where you declare “if the app calls endpoint X, return Y.” Create a file at src/mocks/handlers.js:
import { http, HttpResponse } from 'msw'
export const handlers = [
// GET /api/users — returns a list of mock users
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice Johnson', role: 'admin' },
{ id: 2, name: 'Bob Smith', role: 'user' },
])
}),
// POST /api/login — simulate login
http.post('/api/login', async ({ request }) => {
const body = await request.json()
if (body.username === 'admin' && body.password === '1234') {
return HttpResponse.json({ token: 'fake-jwt-token', success: true })
}
return HttpResponse.json(
{ message: 'Invalid username or password' },
{ status: 401 }
)
}),
// GET /api/products/:id — dynamic route param
http.get('/api/products/:id', ({ params }) => {
const { id } = params
return HttpResponse.json({
id,
name: `Product #${id}`,
price: 99000,
})
}),
]
Before pasting JSON responses into a handler, I like to use toolcraft.app/en/tools/developer/json-formatter to quickly format and validate the structure — much more convenient than installing a browser extension.
3. Set up for the browser environment (dev server)
Run the following command to have MSW generate the Service Worker file:
npx msw init public/ --save
This creates public/mockServiceWorker.js — the actual worker script that runs in the browser.
Create src/mocks/browser.js:
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)
Activate it in src/main.js (or index.js):
async function enableMocking() {
if (process.env.NODE_ENV !== 'development') return
const { worker } = await import('./mocks/browser')
return worker.start()
}
enableMocking().then(() => {
// mount app here
ReactDOM.createRoot(document.getElementById('root')).render(<App />)
})
Run npm run dev and open DevTools — you’ll see the log line: “[MSW] Mocking enabled”. From this point on, every request that matches a handler gets intercepted — nothing goes out to the real internet.
4. Set up for unit tests (Vitest / Jest)
In Node.js there’s no Service Worker, so MSW uses its own HTTP interceptor. Create src/mocks/server.js:
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
In your Vitest setup file (src/setupTests.js):
import { server } from './mocks/server'
import { afterAll, afterEach, beforeAll } from 'vitest'
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers()) // reset after each test
afterAll(() => server.close())
And in vite.config.js:
test: {
setupFiles: './src/setupTests.js',
}
Writing Real Tests with MSW
Here’s what tests actually look like when using MSW. This is an example of testing a React component that calls an API:
import { render, screen, waitFor } from '@testing-library/react'
import { server } from '../mocks/server'
import { http, HttpResponse } from 'msw'
import UserList from './UserList'
test('renders user list', async () => {
render(<UserList />)
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Alice Johnson')).toBeInTheDocument()
})
})
test('shows error message when API fails', async () => {
// Override handler for this test only
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 }
)
})
)
render(<UserList />)
await waitFor(() => {
expect(screen.getByText(/an error occurred/i)).toBeInTheDocument()
})
})
The second test case uses server.use() to override the handler only within that test’s scope. When the test finishes, afterEach(() => server.resetHandlers()) automatically rolls back to the default handler — no manual cleanup needed.
Tips for Using MSW More Effectively
Delay responses to test loading states
import { delay } from 'msw'
http.get('/api/users', async () => {
await delay(1500) // simulate slow network — 1.5 second delay
return HttpResponse.json([...])
})
Use passthrough to let certain requests reach the real server
import { passthrough } from 'msw'
http.get('/api/public-data', () => {
return passthrough() // no mock — request goes straight to the real server
})
Split handlers by feature
// handlers/users.js
export const userHandlers = [http.get('/api/users', ...), http.post('/api/users', ...)]
// handlers/products.js
export const productHandlers = [http.get('/api/products', ...)]
// handlers/index.js
export const handlers = [...userHandlers, ...productHandlers]
This approach keeps handlers easy to find and maintain as the project grows.
From a Handy Tool to a Good Habit
After integrating MSW, I started writing tests far more often. The reason is simple: tests no longer depend on whether the server is running or the API is available. Boot up the machine, run npm test, and you get results immediately.
When the backend changes an API contract, you just update the handlers in one place. Both the dev environment and the test suite get updated automatically. No more tests passing while the real UI is broken because the data structure changed.
MSW doesn’t replace integration tests against a real API — you still need those. But it solves exactly the problem I face every day: develop faster, test more easily, and stop letting frontend and backend block each other. It’s especially valuable in teams with a clear separation between the two sides.

