Speed up frontend development with MSW: write mocks once, use everywhere

As frontend developers, we often face a common bottleneck: the backend API isn’t ready, or it’s unstable.

To keep working, we usually resort to hardcoding data in our components or simulating network delays with setTimeout. While this works temporarily, it creates “technical debt.” When the real API is finally ready, we have to go back, delete the fake code, and wire up the real endpoints.

In our project, we solved this using MSW (Mock Service Worker). While MSW is standard for unit testing, we found it incredibly powerful when integrated directly into our development environment.

The Concept

The idea is simple: don’t fake the component logic; intercept the network request.

MSW works by using a Service Worker to intercept HTTP requests. If the request matches a defined handler, MSW returns a mocked response. To your application, it looks exactly like a real network call.

The best part? We use the exact same mock handlers for our Vitest unit tests and our local development server.

How We Implemented It

Here is a look at how we integrated MSW into our React application efficiently.

1. The Entry Point (main.tsx)

Performance is key. We do not want MSW code in our production bundle. We use a dynamic import to lazy-load the worker only when specific environment variables are set.

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { IS_DEV_ENV, IS_MSW_ENABLED } from "@/constants";
import App from "./App";

const initApp = async () => {
  // Only start the worker if we are in Dev and MSW is enabled
  if (IS_DEV_ENV && IS_MSW_ENABLED) {
    // Dynamic import ensures this code is tree-shaken out of production
    const { worker } = await import("@/tests/browser");
    await worker.start();
  }

  const container = document.getElementById("root") as HTMLElement;
  const root = createRoot(container);

  root.render(
    <StrictMode>
      <App />
    </StrictMode>,
  );
};

initApp();

2. Selective Interception (browser.ts)

In a real-world scenario, you rarely mock everything. You might want to mock a new feature while letting other requests hit the real API.

We created a “Middleware” handler that sits at the top of our handler list. It acts as a gatekeeper, deciding which requests should be mocked and which should go to the real internet.

import { MSW_ENDPOINTS_TO_INTERCEPT } from "@/constants";
import { http, passthrough, setupWorker } from "msw/browser";
import fallbackHandlers from "./server/handlers";

const handlers = [
  // The Gatekeeper Handler
  http.all("*", ({ request }) => {
    // 1. Always allow external services (like Sentry) to pass through
    if (request.url.includes("sentry.your-company.com")) {
      return passthrough();
    }

    // 2. Check if the URL is in our "Intercept List"
    const isTargeted = MSW_ENDPOINTS_TO_INTERCEPT.some((url) => 
      request.url.includes(url)
    );

    // 3. If it's NOT targeted, let it pass through to the real server
    if (!isTargeted) {
      return passthrough();
    }

    // 4. If we are here, we want to mock it. 
    // We return nothing so MSW continues to the specific handlers below.
    return;
  }),

  // Spread our actual feature handlers
  ...fallbackHandlers,
];

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

3. Dynamic Responses

One major advantage of this approach is simulating edge cases. We created a controller helper that allows us to force specific states (like “Error 500” or “Empty List”) without changing code.

In our handlers:

http.get(`${API_URL}features`, () => {
  // Check the global test state
  const status = getEndpointStatus();

  if (status === "error") {
    return new HttpResponse(null, { status: 500 });
  }

  // Return happy path data...
});

The Workflow

We control this behavior using .env.local variables. This makes it very easy to switch contexts without touching the code.

When we start a new feature, we define the route we want to build:

VITE_MSW_ENABLED=true
VITE_MSW_ENDPOINTS_TO_INTERCEPT=/my-new-feature

Now, fetch('/my-new-feature') returns our mock data immediately. The rest of the app continues to fetch data from the real backend. When the backend team finishes their work, we just remove that endpoint from the env variable, and the app automatically switches to the real API.

Conclusion

By moving mocks out of components and into the network layer, we:

  1. Reuse code: The same mocks power our Vitest suite and our manual testing.
  2. Develop faster: We aren’t blocked by backend availability.
  3. Keep code clean: No temporary hardcoded data in React components.

If you haven’t tried MSW in the browser yet, give it a shot. It bridges the gap between testing and development beautifully.