3 API Mock Tools: Intercept Fetch, Ship UI 40% Faster

It's 4:47 PM on Thursday, and your demo is Monday

The Slack message lands like a brick: "Analytics endpoints pushed to next sprint. Backend is swamped with the auth refactor. Sorry."

You stare at your perfectly styled dashboard. The charts are rendered. The layout is responsive. The Figma file has 47 data points mapped across 3 views. But right now, your app calls fetch('/api/v1/analytics/overview') and gets back a 404 from the dev server. Zero data. Zero motion. Zero demo.

This is the moment where most frontend developers reach for the same rusty toolbox: a hardcoded JSON file imported at the top of a component, or maybe a const mockData = {...} block commented with "// TODO: replace with real API." You know how that story ends. Monday morning, someone asks "Can I click into the user detail view?" and your prototype freezes because the mock doesn't have nested routes. Or worse — you switch a boolean flag from USE_MOCK to true, forget to flip it back, and ship mock data to staging.

There's a better path. And it doesn't involve begging the backend team for a half-built endpoint.

Why your current mock strategy keeps collapsing

Let's be honest about what goes wrong, because the pattern is predictable.

The hardcoded data trap

You drop a JSON file into your src/ folder and import it. Works for a static screenshot. Falls apart the moment someone interacts with the prototype. Your mock has 12 users. The pagination component expects 50. The date range filter? Your mock has one timestamp, not a range. The search bar returns nothing because there's no logic — just a static array.

The environment flag house of cards

So you get clever. You write if (USE_MOCK) { return mockData } else { return fetch(...) } inside every API call function. Now you have 14 files with conditional branches, and not one of them is tested against the real request shape. When the backend ships, you'll discover your mock expected { user_count: 342 } but the real API returns { totalUsers: 342 }. Different key names. Different casing. Your components break anyway.

The "I'll just use Postman" detour

Postman and similar API mock tools are powerful, but they run outside your app. You're prototyping in the browser, not in a separate desktop client. The context switch kills your flow, and the mock server URL (localhost:3001) doesn't match your production API path (/api/v1/...). You end up rewriting paths anyway.

The root cause across all three: your mock layer lives in the wrong place. It's either baked into your component code, scattered across environment flags, or stranded in an external tool. What you need is a layer that sits between your app and the network — one that intercepts requests at the protocol level, returns whatever you want, and leaves your actual application code untouched.

That layer already exists in your browser. It's called a service worker.

Service workers as your invisible mock layer

Here's the promise: by the end of this section, you'll have a service worker that intercepts every fetch call your prototype makes, returns realistic mock data with dynamic placeholders, and requires exactly zero changes to your existing component code. The same fetch('/api/v1/analytics/overview') call will work in your prototype today and against the real backend next sprint.

Let's walk through it with a concrete example.

The scenario: analytics dashboard, 3 endpoints, Monday deadline

Your dashboard needs three endpoints to look alive in the demo:

1. GET /api/v1/analytics/overview — returns total users, revenue, and churn rate 2. GET /api/v1/analytics/users?page=1 — returns paginated user list with name, signup date, and plan 3. GET /api/v1/analytics/users/:id — returns detail view for a single user

Twelve stakeholders will be in the room Monday. They will click things. Your mock needs to handle that.

Step 1: Register the service worker

Create a file called mock-sw.js in your public/ directory. Then register it in your app entry point — something like main.ts or index.js:

if (process.env.NODE_ENV !== 'production') {
  navigator.serviceWorker.register('/mock-sw.js');
}

Three lines. Zero dependencies. The conditional ensures this never touches production — your mock layer is dev-only by design.

Step 2: Intercept fetch calls with dynamic responses

Inside mock-sw.js, you'll listen for fetch events and respond with mock data before the request ever hits the network. Here's what that looks like:

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

  // Overview endpoint
  if (url.pathname === '/api/v1/analytics/overview') {
    event.respondWith(new Response(JSON.stringify({
      total_users: 3420,
      revenue: 184200,
      churn_rate: 0.032
    }), {
      headers: { 'Content-Type': 'application/json' }
    }));
    return;
  }

  // Paginated users endpoint
  if (url.pathname.startsWith('/api/v1/analytics/users')) {
    const page = parseInt(url.searchParams.get('page') || '1');
    const pageSize = 10;
    const allUsers = Array.from({ length: 50 }, (_, i) => ({
      id: i + 1,
      name: `User ${i + 1}`,
      signup_date: new Date(Date.now() - i * 86400000).toISOString(),
      plan: ['free', 'pro', 'enterprise'][i % 3]
    }));

    const startIndex = (page - 1) * pageSize;
    const paginatedUsers = allUsers.slice(startIndex, startIndex + pageSize);

    event.respondWith(new Response(JSON.stringify({
      users: paginatedUsers,
      total: 50,
      page: page,
      total_pages: 5
    }), {
      headers: { 'Content-Type': 'application/json' }
    }));
    return;
  }

  // Single user detail — dynamic ID from URL
  const userMatch = url.pathname.match(/^\/api\/v1\/analytics\/users\/(\d+)$/);
  if (userMatch) {
    const userId = parseInt(userMatch[1]);
    event.respondWith(new Response(JSON.stringify({
      id: userId,
      name: `User ${userId}`,
      email: `user${userId}@example.com`,
      signup_date: '2024-01-15T10:30:00Z',
      plan: 'pro',
      last_login: '2024-06-20T14:22:00Z',
      usage: { api_calls: 1240, storage_gb: 2.4 }
    }), {
      headers: { 'Content-Type': 'application/json' }
    }));
    return;
  }

  // Let everything else pass through normally
  event.respondWith(fetch(event.request));
});

Step 3: Watch your prototype come alive

Save the file. Refresh the page. Your dashboard now has 50 users across 5 pages, dynamic detail views for every single one, and a working overview endpoint — all without touching a single line of component code. The search bar works if you add a filter to the mock. The date range works if you generate timestamps across a range. Click into any user, and the detail view populates instantly because the mock handles :id dynamically.

No USE_MOCK flags. No commented-out TODOs. No JSON files in your src/ folder. When the backend ships the real endpoints next sprint, you unregister the service worker or simply remove the registration block. Your app code doesn't change at all.

This is the raw service worker approach — powerful, dependency-free, and a great learning exercise. But writing raw Response objects by hand gets tedious when your API surface grows past a handful of endpoints. That's where purpose-built mock libraries come in.

Tool 1: MSW (Mock Service Worker)

MSW takes the exact service worker concept we just built and wraps it in a clean, declarative API. Instead of manually constructing Response objects, you define handlers that read almost like a spec sheet for your API.

What it looks like in practice

import { http, HttpResponse } from 'msw';
import { setupWorker } from 'msw/browser';

const handlers = [
  http.get('/api/v1/analytics/overview', () => {
    return HttpResponse.json({
      total_users: 3420,
      revenue: 184200,
      churn_rate: 0.032
    });
  }),

  http.get('/api/v1/analytics/users', ({ request }) => {
    const url = new URL(request.url);
    const page = parseInt(url.searchParams.get('page') || '1');
    const pageSize = 10;
    const allUsers = Array.from({ length: 50 }, (_, i) => ({
      id: i + 1,
      name: `User ${i + 1}`,
      signup_date: new Date(Date.now() - i * 86400000).toISOString(),
      plan: ['free', 'pro', 'enterprise'][i % 3]
    }));
    const startIndex = (page - 1) * pageSize;

    return HttpResponse.json({
      users: allUsers.slice(startIndex, startIndex + pageSize),
      total: 50,
      page,
      total_pages: 5
    });
  }),

  http.get('/api/v1/analytics/users/:id', ({ params }) => {
    const userId = parseInt(params.id);
    return HttpResponse.json({
      id: userId,
      name: `User ${userId}`,
      email: `user${userId}@example.com`,
      signup_date: '2024-01-15T10:30:00Z',
      plan: 'pro',
      last_login: '2024-06-20T14:22:00Z',
      usage: { api_calls: 1240, storage_gb: 2.4 }
    });
  })
];

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

Notice what's different: the handlers are readable, the URL matching is declarative (no regex), and HttpResponse.json() handles serialization and headers automatically. You also get request interception that works identically in Node.js — meaning you can reuse these same handlers for integration tests in Jest or Vitest without rewriting a single mock.

Where MSW shines

  • Zero app code changes: Same as raw service workers. Your fetch calls stay untouched.
  • Shared handlers for dev and testing: One mock definition powers your prototype and your test suite. No drift.
  • Request inspection: MSW logs every intercepted request in the browser console, so you can see exactly what your app is calling and when.
  • Error simulation: Want to test how your UI handles a 500? Add a handler that returns HttpResponse.json({ error: 'Server down' }, { status: 500 }). Takes 10 seconds.

The trade-off

MSW adds a small dependency (~7KB gzipped) and requires an initial setup step where you generate the worker file using npx msw init. For teams that already use a test runner, this is a non-issue. For a solo dev building a quick prototype, the raw service worker approach from the previous section might be enough. But once your mock surface exceeds 5 endpoints, MSW's ergonomics pay for themselves fast.

Tool 2: Mirage JS

Mirage JS takes a different philosophical approach. Instead of intercepting at the service worker level, it patches XMLHttpRequest and fetch in JavaScript runtime. This means it works in any JavaScript environment — browser, Node, test runner — without needing a service worker registration step.

What makes Mirage different

Mirage doesn't just mock responses. It gives you an in-memory database. You define models, relationships, and seed data, then Mirage handles the CRUD logic for you automatically.

import { createServer, Model, Factory } from 'miragejs';

createServer({
  models: {
    user: Model
  },

  factories: {
    user: Factory.extend({
      name(i) { return `User ${i + 1}`; },
      signupDate() { return new Date().toISOString(); },
      plan() { return ['free', 'pro', 'enterprise'][Math.floor(Math.random() * 3)]; }
    })
  },

  seeds(server) {
    server.createList('user', 50);
  },

  routes() {
    this.namespace = '/api/v1/analytics';

    this.get('/overview', () => ({
      total_users: 3420,
      revenue: 184200,
      churn_rate: 0.032
    }));

    this.get('/users', (schema, request) => {
      const page = parseInt(request.queryParams.page || '1');
      const pageSize = 10;
      const allUsers = schema.users.all().models;
      const startIndex = (page - 1) * pageSize;

      return {
        users: allUsers.slice(startIndex, startIndex + pageSize),
        total: allUsers.length,
        page,
        total_pages: Math.ceil(allUsers.length / pageSize)
      };
    });

    this.get('/users/:id', (schema, request) => {
      return schema.users.find(request.params.id);
    });
  }
});

See the difference? With Mirage, you're not hand-writing JSON for each user. You define a factory, seed 50 records, and the database handles lookups by ID automatically. If you later need a POST /users endpoint to create a new user, Mirage handles it with this.post('/users') — the record gets inserted into the in-memory database and is immediately available on subsequent GET calls.

Where Mirage shines

  • Stateful mocks: Create a user in the mock, and it persists across requests during the session. Great for demos where stakeholders want to "add a record and see it appear."
  • Relationships: Define user.hasMany('sessions') and Mirage handles nested data automatically.
  • No service worker setup: Works in environments where service workers aren't available (like some test runners or older browsers).
  • Rapid prototyping of full CRUD flows: If your demo needs create/edit/delete, Mirage gives you that almost for free.

The trade-off

Mirage patches global fetch, which means it modifies JavaScript runtime behavior. This is generally fine, but it can occasionally conflict with other libraries that also patch fetch (some analytics SDKs, for example). It also doesn't intercept requests at the network level — if your app makes a request that Mirage doesn't have a route for, it will hit the real network and likely 404, rather than passing through cleanly.

Tool 3: Fetch Interceptors (Custom Wrapper)

Sometimes you don't want a library at all. Sometimes you want a thin wrapper around fetch that lets you define mocks inline, toggle them per-environment, and keep everything in your own codebase. This is the lightest possible approach — no service worker, no dependency, no global patching.

The pattern

const mocks = {
  '/api/v1/analytics/overview': () => ({
    total_users: 3420,
    revenue: 184200,
    churn_rate: 0.032
  }),
  '/api/v1/analytics/users': (url) => {
    const page = parseInt(new URL(url).searchParams.get('page') || '1');
    return {
      users: Array.from({ length: 10 }, (_, i) => ({
        id: (page - 1) * 10 + i + 1,
        name: `User ${(page - 1) * 10 + i + 1}`,
        signup_date: new Date().toISOString(),
        plan: 'pro'
      })),
      total: 50,
      page,
      total_pages: 5
    };
  }
};

const originalFetch = window.fetch;
window.fetch = async (input, init) => {
  const url = typeof input === 'string' ? input : input.url;

  for (const [pattern, handler] of Object.entries(mocks)) {
    if (url.includes(pattern)) {
      return new Response(JSON.stringify(handler(url)), {
        headers: { 'Content-Type': 'application/json' }
      });
    }
  }

  return originalFetch(input, init);
};

Where this approach shines

  • Zero dependencies: Nothing to install, nothing to maintain.
  • Full control: You own every line. No abstraction layer to fight.
  • Trivial to remove: Delete the wrapper, and your app reverts to standard fetch instantly.
  • Great for small prototypes: If you have 3-5 endpoints and just need them to return something, this is the fastest path.

The trade-off

You're back to patching fetch globally, which means you need to be careful about load order — the patch must run before any API calls. You also lose the network-level interception that service workers provide, meaning requests that don't match a mock will still hit the real network. And unlike MSW or Mirage, there's no built-in way to reuse these mocks in your test suite — you'd need to export the mocks object and wire it up yourself.

Choosing the right tool for your situation

All three approaches solve the same core problem: they put your mock layer between your app and the network, not inside your components. But they optimize for different things.

Need Best choice
Quick prototype, 3-5 endpoints, no dependencies Fetch interceptor wrapper
Shared mocks between dev and testing, growing API surface MSW
Stateful CRUD flows, relationships, demo with create/edit/delete Mirage JS
Maximum control, no library, service worker available Raw service worker (from earlier section)

For most teams, MSW hits the sweet spot. It gives you the network-level interception of raw service workers, the declarative ergonomics that make large mock surfaces manageable, and the shared-handler story that keeps your dev mocks and test mocks in sync. Start there, and reach for Mirage if your prototype needs stateful interactions that go beyond read-only data.

The Bottom Line

Every frontend developer has lived through the "backend is delayed" Slack message. The difference between a prototype that survives that message and one that doesn't comes down to where your mock layer lives.

If your mocks are hardcoded JSON files in your src/ folder, you'll spend Monday morning explaining why the pagination doesn't work. If they're scattered across environment flags, you'll ship mock data to staging by accident. If they live in an external tool like Postman, you'll lose your flow switching contexts.

But if your mocks sit at the network level — intercepting fetch calls before they leave the browser, returning realistic data with dynamic placeholders, and leaving your application code completely untouched — your prototype becomes a living, clickable, demo-ready thing. The same code that runs against your mocks today will run against the real API next sprint without a single line changed.

That's not just a mock strategy. That's how you ship UI 40% faster. You stop waiting for endpoints. You stop rewriting component code when the backend ships. You stop debugging USE_MOCK flags at 11 PM on a Sunday. You build the UI, you wire up the fetch calls, and you let the mock layer handle the rest until the real API is ready.

Pick one of the three tools above. Set it up before your next prototype. Your future self — the one staring at a Slack message at 4:47 PM on a Thursday — will thank you.

Frequently Asked Questions

How do I mock the fetch API using service workers?

You can mock the fetch API by registering a service worker that intercepts outgoing network requests using the fetch event listener. Within this event, you can return custom Response objects instead of letting the request hit the actual network. This allows you to simulate API responses directly in the browser without modifying your frontend code.

Why use service workers for frontend prototyping instead of traditional API mocking?

Service workers intercept network requests at the browser level, meaning you don't have to set up a separate mock server or change your application's source code. This enables a highly realistic testing environment because your app makes standard fetch calls just like it would in production. It also allows you to easily prototype offline scenarios and dynamic responses.

How can I generate dynamic placeholders for mocked API responses?

When intercepting a fetch request in a service worker, you can parse the request URL and use data generation libraries to create dynamic, realistic placeholder data. By returning this dynamically generated JSON in your mock Response, you can simulate different API states, pagination, or user-specific data without hardcoding values.

What are the best API mock tools that utilize service workers?

Mock Service Worker (MSW) is currently the most popular and highly recommended tool for intercepting fetch requests via service workers. It provides a clean API for defining mock responses and supports both REST and GraphQL out of the box. Other alternatives include setting up custom service worker scripts manually or using local proxy tools.

Can I mock GraphQL requests using a service worker?

Yes, service workers can easily intercept and mock GraphQL queries and mutations by listening to POST requests directed at your GraphQL endpoint. You can parse the request body to determine the specific query type and return a tailored, dynamically generated mock response. Tools like MSW make this process straightforward with built-in GraphQL mocking support.

How do I bypass certain fetch requests in my service worker mock?

If you want to mock specific endpoints while allowing others to reach the real server, you can conditionally check the request URL inside your fetch event listener. If the URL matches your mock criteria, return a mocked response; otherwise, call the original fetch request to pass it through. This hybrid approach is perfect for gradual frontend prototyping.

How do I handle dynamic URL parameters when mocking the fetch API?

You can use regular expressions to match dynamic URL parameters, such as user IDs or slugs, within your service worker's fetch event listener. Once matched, you can extract these parameters to generate contextual dynamic placeholders in your mock response. This ensures your frontend prototype accurately handles route-specific data fetching.

Are service worker API mocks visible in the browser's Network tab?

Yes, when a service worker intercepts a fetch request and returns a mock response, the request still appears in your browser's DevTools Network tab. You will typically see a flag indicating the request was handled by a service worker. This makes debugging your frontend prototype just as easy as working with real API responses.

Does mocking APIs with service workers work in Node.js for backend testing?

While service workers are a browser-specific technology, modern mock tools like MSW offer a Node.js integration that intercepts requests using native HTTP modules. This means you can write the exact same mock definitions for both your frontend prototyping and your backend unit tests. It provides a seamless mocking experience across different JavaScript environments.