MSW: Stop Waiting for the Backend and Mock Your APIs
The Hardcoded Mess I Made
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.
The Concept That Finally Clicked
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.
Why Other Approaches Fall Short
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.
How It Actually Works
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
Setting It Up
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.
Writing Handlers
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 }
);
}),
];
Browser Setup
// 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 />);
});
Test Setup
// 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.
Patterns Worth Keeping
A few patterns that made the biggest difference in practice.
Error Simulation
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.
Delay Simulation
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" }]);
});
Per-Test Handler Overrides
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.
Key Takeaways
-
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.