
MSW: Stop Waiting for the Backend and Mock Your APIs
Frontend development blocked by unfinished APIs. MSW intercepts requests at the network level so you can build and test without a real backend.

Frontend development blocked by unfinished APIs. MSW intercepts requests at the network level so you can build and test without a real backend.
Tired of naming classes? Writing CSS directly inside HTML sounds ugly, but it became the world standard. Why?

Writing test BEFORE code. Red -> Green -> Refactor. The Double-Entry Bookkeeping of Programming.

Class is there, but style is missing? Debugging Tailwind CSS like a detective.

For visually impaired, for keyboard users, and for your future self. Small `alt` tag makes a big difference.

It was week two of the sprint. I had a dashboard to build. The design was ready. The API spec was written. But the actual API wasn't there yet — the backend team needed another week.
Two choices: wait, or figure something out.
Waiting wasn't an option. So I did what any exhausted frontend dev does.
// Temporary... please nobody look at this
const MOCK_USER = {
id: "1",
name: "Test User",
email: "test@example.com",
};
const MOCK_POSTS = [
{ id: "1", title: "First Post", status: "published" },
{ id: "2", title: "Second Post", status: "draft" },
];
export async function getUser() {
// TODO: Remove before connecting real API
return MOCK_USER;
}
I thought this was a quick temporary patch. Then it became two files. Then ten. Then thirty. When the real API finally landed, hunting down every scattered MOCK_* constant across the codebase took longer than actually wiring up the API.
That's when I found MSW.
MSW (Mock Service Worker) intercepts API requests at the network level.
Not by replacing your fetch calls with hardcoded data. Not by running a separate server process. MSW sits between your app and the network, intercepts outgoing requests, and returns fake responses. Your app code has no idea it's talking to a mock. It just gets a response and moves on.
The analogy that made it click for me: MSW is a movie set.
When a film crew shoots a coffee shop scene, they don't go to an actual coffee shop. They build a set in a studio that looks like one. The actor walks in, orders coffee, interacts with the barista. From the actor's perspective, it's a coffee shop. They don't need to know it's a set — they just need to play the scene convincingly.
MSW works exactly like that. Your app code (the actor) calls fetch("/api/user") (orders coffee). MSW (the set) intercepts that request and returns a fake response (a prop coffee cup). Your app code never knows there was no real backend.
Before MSW, the common alternatives all have problems:
| Approach | The Problem |
|---|---|
| Hardcoded mock constants | Scattered across files, painful to remove |
| json-server | Separate process, separate config, limited logic |
| Axios interceptors | Doesn't work with native fetch, more maintenance surface |
if (isDev) branches | Dev code bleeds into production code |
MSW solves all of these because it operates at the network layer — entirely outside your app code.
The mechanism behind MSW is the Service Worker API.
A service worker is a script that runs between your browser and the network. Its original purpose was offline caching for PWAs — intercept network requests, serve cached responses when there's no internet. MSW repurposes this for API mocking.
Think of it as a stunt double. When a scene is too dangerous or the other actor isn't available yet, a stunt double steps in. The main actor (your app code) performs their part normally. They don't need to know who's playing opposite them — they just respond to what they get back.
In tests, MSW swaps out the Service Worker for a Node.js request interceptor. Same handlers, different interception mechanism. You write your mock handlers once and they work in both browser development and test environments.
[App code]
↓ fetch("/api/user")
[MSW / Service Worker]
↓ "I'll handle this one"
↓ Runs matching handler
↑ Returns { id: "1", name: "Kim Dev" }
[App code]
↑ Receives response, assumes it's real
Install and initialize:
npm install msw --save-dev
npx msw init public/ --save
The second command generates public/mockServiceWorker.js — the actual service worker script. MSW manages this file. Don't edit it directly.
Handlers define "for this request, return this response." They're just functions.
// src/mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
// GET /api/user
http.get("/api/user", () => {
return HttpResponse.json({
id: "1",
name: "Kim Dev",
email: "dev@example.com",
role: "admin",
});
}),
// GET /api/posts with query params
http.get("/api/posts", ({ request }) => {
const url = new URL(request.url);
const status = url.searchParams.get("status");
const allPosts = [
{ id: "1", title: "First Post", status: "published" },
{ id: "2", title: "Second Post", status: "draft" },
{ id: "3", title: "Third Post", status: "published" },
];
const filtered = status
? allPosts.filter((p) => p.status === status)
: allPosts;
return HttpResponse.json(filtered);
}),
// POST /api/posts
http.post("/api/posts", async ({ request }) => {
const body = await request.json() as { title: string; content: string };
return HttpResponse.json(
{
id: crypto.randomUUID(),
title: body.title,
status: "draft",
createdAt: new Date().toISOString(),
},
{ status: 201 }
);
}),
];
// src/mocks/browser.ts
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
// src/main.tsx
async function enableMocking() {
if (process.env.NODE_ENV !== "development") {
return; // MSW never runs in production
}
const { worker } = await import("./mocks/browser");
return worker.start({
onUnhandledRequest: "bypass", // Real requests pass through
});
}
enableMocking().then(() => {
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
});
// src/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
// src/setupTests.ts
import { server } from "./mocks/server";
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // Prevent test contamination
afterAll(() => server.close());
Now your tests just work. No mocking fetch. No jest spies on network calls.
test("renders user profile", async () => {
render(<UserProfile />);
await waitFor(() => {
expect(screen.getByText("Kim Dev")).toBeInTheDocument();
});
});
The test has zero knowledge of the network layer. UserProfile calls fetch("/api/user"), MSW intercepts it, returns the handler response. The test asserts on what the user sees.
A few patterns that made the biggest difference in practice.
Building error states is frustrating when you can't reliably trigger errors from a real server. MSW makes this trivial.
// Server error
http.get("/api/user", () => {
return HttpResponse.json({ message: "Internal Server Error" }, { status: 500 });
});
// Network failure (can't reach server at all)
http.get("/api/user", () => {
return HttpResponse.error();
});
Being able to develop error-handling UI against actual error conditions — not just visually faked states — caught bugs I would have shipped otherwise.
Skeleton screens and loading spinners are impossible to develop properly when responses arrive in 0ms.
import { http, HttpResponse, delay } from "msw";
http.get("/api/posts", async () => {
await delay(1500); // Simulate slow connection
return HttpResponse.json([{ id: "1", title: "Slow post" }]);
});
Default handlers return success. For tests that need specific error states, override inline:
test("shows error message on API failure", async () => {
server.use(
http.get("/api/user", () => {
return HttpResponse.json({ message: "Server Error" }, { status: 500 });
})
);
render(<UserProfile />);
await waitFor(() => {
expect(screen.getByText("Something went wrong.")).toBeInTheDocument();
});
// afterEach resets to default handlers automatically
});
This pattern — success by default, override for edge cases — keeps test setup minimal and readable.
MSW operates at the network level. Your app code never needs to change. Whether it's hitting a real API or MSW, the fetch calls are identical.
One handler file works everywhere. Same handlers run in browser development (via Service Worker) and in tests (via Node interceptor). No duplication.
Handlers are just functions. Conditional responses, error simulation, delays, request body parsing — you write code, not config. Far more flexible than json-server's db.json.
Per-test overrides with server.use() keep your test suite clean. Default success, override for errors. resetHandlers() prevents cross-test contamination.
Production builds are unaffected. The NODE_ENV !== "development" guard means MSW code never ships. No bundle bloat, no accidental mocking in production.
When the real API is ready, you flip a switch — remove the MSW startup call in your entry point. Everything else stays the same. That's the clean exit the hardcoded MOCK_* approach never gave me.