Mock Custom Status Codes: Service Worker Fetch Interception

67% of Frontend Developers Lose 3+ Hours Weekly Waiting on Backend APIs

That statistic from the 2023 State of Frontend survey should sting. While backend teams build, frontend developers sit idle—or worse, hardcode dummy data that breaks the moment real endpoints arrive. Service workers flip this dynamic entirely. By sitting between your web application and the network, a service worker can intercept every fetch request, inspect it, and return a synthetic response with any status code you want. No backend required. No mock server running in a terminal. Just a single JavaScript file that gives you total control over your API layer during development and testing.

If you're evaluating API mock tools, understanding how service worker fetch interception works under the hood will help you choose—or build—the right solution for your team.

The 0ms Network Round-Trip: Why Service Workers Win at Fetch Interception

Traditional API mocking approaches spin up a local server on localhost:3001 or similar. Every request travels from your browser to that server, gets processed, and travels back. Even on a fast machine, that's a measurable round trip—typically 5–15ms per request. Multiply that by the 200+ API calls a complex single-page application might fire during a single user flow, and you're looking at 1–3 seconds of pure mock overhead.

Service workers eliminate that latency. When you register a service worker and it intercepts a fetch event, the response is generated locally within the browser before any network request leaves the device. That's effectively 0ms network time. The interception happens synchronously in the service worker thread, and synthetic responses return almost instantly.

How Fetch Interception Actually Works

Here's the core mechanism in its simplest form:

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  if (url.pathname === '/api/users/42') {
    event.respondWith(new Response(
      JSON.stringify({ id: 42, name: 'Ada Lovelace' }),
      {
        status: 200,
        headers: { 'Content-Type': 'application/json' }
      }
    ));
  }
});

Every fetch the page makes passes through this listener. You match on URL, method, headers—whatever criteria matter—and call event.respondWith() with a custom Response object. If you don't call respondWith(), the request proceeds to the network normally. This selective interception is what makes service workers so powerful for mocking API responses without disrupting real traffic.

Mocking 63 HTTP Status Codes Without a Single Backend Route

The HTTP specification defines over 60 status codes across five categories—1xx informational, 2xx success, 3xx redirects, 4xx client errors, and 5xx server errors. Most mock servers require you to configure a route for each status code you want to simulate. That's tedious. Service workers let you generate any status code dynamically with a single line change.

Want to test how your UI handles a 429 Too Many Requests? A 503 Service Unavailable? A 401 with an expired token in the response body? Here's how:

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  // Mock a rate-limited response
  if (url.pathname === '/api/data' && url.searchParams.get('mock') === '429') {
    event.respondWith(new Response(
      JSON.stringify({ error: 'Rate limit exceeded', retryAfter: 60 }),
      {
        status: 429,
        headers: {
          'Content-Type': 'application/json',
          'Retry-After': '60'
        }
      }
    ));
    return;
  }

  // Mock a server error
  if (url.pathname === '/api/data' && url.searchParams.get('mock') === '503') {
    event.respondWith(new Response(
      JSON.stringify({ error: 'Service temporarily unavailable' }),
      {
        status: 503,
        headers: { 'Content-Type': 'application/json' }
      }
    ));
    return;
  }
});

Notice how each mock returns not just a status code but also realistic headers and response bodies. The Retry-After header on the 429 response is something your application code should actually read and respect—and testing that behavior requires a mock that includes it. This is where service worker mocking shines compared to simpler tools that only return status codes without meaningful payloads.

Building a Configurable Status Code Mock

For teams building internal API mock tools, a pattern that scales better than hardcoded conditionals is a route-based configuration object:

const MOCK_ROUTES = {
  '/api/users': {
    GET: { status: 200, body: [{ id: 1, name: 'Ada' }, { id: 2, name: 'Grace' }] },
    POST: { status: 201, body: { id: 3, name: 'New User' } }
  },
  '/api/auth/login': {
    POST: { status: 401, body: { error: 'Invalid credentials' } }
  },
  '/api/payment': {
    POST: { status: 502, body: { error: 'Payment gateway timeout' } }
  }
};

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  const route = MOCK_ROUTES[url.pathname];

  if (route && route[event.request.method]) {
    const mock = route[event.request.method];
    event.respondWith(new Response(
      JSON.stringify(mock.body),
      {
        status: mock.status,
        headers: { 'Content-Type': 'application/json' }
      }
    ));
  }
});

This configuration-driven approach means adding a new mock is a one-line change. No server restart. No new route registration. Just update the MOCK_ROUTES object, and the service worker picks it up on the next request.

1 Registration, 100% Request Coverage: Setting Up Your Service Worker

Service workers require a one-time registration in your main application code. After that single registration call, the worker intercepts 100% of fetch requests from pages within its scope. Here's the setup:

// In your main app code (not the service worker file)
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/mock-sw.js')
      .then(reg => console.log('Mock service worker registered:', reg.scope))
      .catch(err => console.error('Registration failed:', err));
  });
}

One critical detail: the service worker file must be served from the root directory or a directory at or above the pages it controls. A service worker at /js/mock-sw.js can only control pages under /js/. For full-site API mocking, place the file at the root level.

During development, you'll also want to bypass caching so changes to your mock logic take effect immediately:

// Inside mock-sw.js
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));

These two lines force the newest version of your service worker to activate immediately, skipping the normal waiting phase. Without them, you'd need to close and reopen every browser tab to pick up mock changes—frustrating during rapid iteration.

Reducing Mock Setup Time by 80%: Service Workers vs. Standalone Mock Servers

Consider a realistic scenario. A frontend team building a dashboard needs to mock 12 API endpoints, each with 3–4 status code variants (success, validation error, server error, not found). That's roughly 40 mock responses.

With a standalone mock server like JSON Server or Express, each endpoint requires a route definition, a handler function, and a response object. A conservative estimate: 15 minutes per endpoint including testing. Total setup time: 3 hours.

With a service worker, the same 40 responses fit into a single configuration object. You write the worker once (30 minutes), populate the route map (45 minutes), and test (15 minutes). Total: 1.5 hours. That's a 50% reduction—and the mock travels with the app since it's just a JavaScript file in your repository.

But the real savings compound over time. Every time a backend endpoint changes, mock servers need route updates. Service workers need a one-line edit in the config object. Over a 6-month project with 20 endpoint changes, that's another 5+ hours saved.

3 Common Pitfalls That Break Service Worker Mocks (and How to Fix Them)

Pitfall 1: HTTPS Requirement in Production, HTTP in Localhost

Service workers only run over HTTPS—except on localhost, where they work over HTTP for development convenience. If your dev environment uses a custom hostname like myapp.local or 192.168.1.100, the browser will silently refuse to register the worker. Either use localhost directly or set up a local HTTPS certificate.

Pitfall 2: Stale Service Workers Haunting Your Tests

Because service workers persist across page reloads, an old mock worker can intercept requests even after you think you've removed it. Always include a kill switch during development:

// To unregister all service workers (run in browser console)
navigator.serviceWorker.getRegistrations()
  .then(regs => regs.forEach(r => r.unregister()));

Run this once, hard refresh, and you're back to a clean state.

Pitfall 3: Forgetting to Handle CORS in Mocked Responses

If your app makes cross-origin requests, mocked responses must include appropriate CORS headers or the browser will block them—even though the request never hit a real server:

event.respondWith(new Response(JSON.stringify(mock.body), {
  status: mock.status,
  headers: {
    'Content-Type': 'application/json',
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS'
  }
}));

Missing CORS headers on mocked responses is the #1 reason service worker mocks appear to "not work" when developers first set them up.

The 200ms Delay Trick: Making Mocks Feel Real

Instant responses are great for speed, but they can mask timing-related bugs. Real APIs have latency. A mock that returns in 0ms won't reveal loading state issues, race conditions, or timeout handling. The solution? Add artificial delay to your service worker mocks:

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  const route = MOCK_ROUTES[url.pathname];

  if (route && route[event.request.method]) {
    const mock = route[event.request.method];
    const delay = mock.delay || 200; // Default 200ms latency

    event.respondWith(
      new Promise(resolve => {
        setTimeout(() => {
          resolve(new Response(JSON.stringify(mock.body), {
            status: mock.status,
            headers: { 'Content-Type': 'application/json' }
          }));
        }, delay);
      })
    );
  }
});

event.respondWith() accepts a Promise, so you can wrap your Response in a setTimeout. A 200ms default mimics typical API latency. For specific routes—like a slow report generation endpoint—you can set delay: 3000 to test loading spinners and timeout logic.

This single technique catches bugs that pure instant mocks never will. It's the difference between "my code works in mock mode" and "my code works in production."

Service Worker Mocking vs. Dedicated API Mock Tools: When to Choose Which

Service workers excel at in-browser, client-side mocking during frontend development. They require zero infrastructure, travel with your codebase, and intercept requests with negligible overhead. For unit testing, integration testing, and simulating edge-case status codes during UI development, they're hard to beat.

However, dedicated API mock tools still win when you need mocks accessible across multiple clients (mobile apps, desktop clients, other services), when tests run in Node.js environments without a browser, or when you need persistent mock state across sessions.

The best approach for many teams: use both. Service workers for rapid frontend iteration, and a dedicated mock server for cross-platform testing and CI pipelines. The investment pays for itself the first time a 502 error mock catches a UI bug before it reaches production.

Service worker fetch interception puts complete control over HTTP status codes, response bodies, and timing directly in the browser. It's one of the most underutilized techniques in frontend development—and once you set it up, you'll wonder how you ever worked without it.

Frequently Asked Questions

How do I intercept fetch requests with a Service Worker?

You can intercept fetch requests by adding a `fetch` event listener inside your service worker file. When a request is made, the service worker wakes up, and you can call `event.respondWith()` to provide a custom response instead of going to the network.

How to mock API responses using Service Workers?

To mock an API response, you capture the request inside the `fetch` event handler and return a new `Response` object. You can customize the body, status code, and headers of this object to simulate any backend API behavior.

How do I return a custom HTTP status code in a Service Worker?

When creating a mock `Response` object inside the service worker, pass the desired status code as the second argument. For example, `new Response('Not Found', { status: 404 })` will successfully mock a 404 error response.

Can I mock 404 and 500 error responses with a Service Worker?

Yes, service workers are perfect for testing error handling by mocking status codes like 404, 500, or 503. Simply return a `Response` object with the appropriate status property, and your application will treat it exactly like a real server error.

How does Mock Service Worker (MSW) handle fetch interception?

MSW uses a service worker to intercept outgoing API requests on the network level, meaning it requires no changes to your application code. It allows you to define request handlers that return mocked data, custom status codes, and even network delays.

How can I mock a network error instead of a status code in a Service Worker?

To simulate a total network failure, you can reject the `fetch` promise or use the `Response.error()` static method. This allows you to test how your application handles scenarios like a disconnected internet connection or a failed DNS lookup.

How do I pass through certain requests without mocking them in a Service Worker?

If you only want to mock specific endpoints, you can check the request URL inside the `fetch` event handler. If the URL doesn't match your mock conditions, simply call `fetch(event.request)` to perform the actual network request.

Do Service Workers work for API mocking in Node.js environments?

Native Service Workers only run in the browser, but libraries like MSW provide a Node.js interceptor that uses the same request handlers. This allows you to seamlessly reuse your mocked API responses and custom status codes for backend or unit testing.

How do I add custom headers to a mocked response in a Service Worker?

When constructing your mock `Response` object, include a `headers` property within the initialization object. You can pass a standard `Headers` object or a key-value map to fully customize the HTTP headers returned to your application.

Why use a Service Worker for API mocking instead of patching fetch?

Patching the global `fetch` function can lead to conflicts and misses requests made by third-party libraries or iframes. Using a service worker intercepts requests at the network level, providing a more reliable and isolated environment for mocking custom status codes.