← All posts

API & helpers

Mock API responses by intercepting fetch and XHR

The backend is not ready. Or it is ready, but you cannot make it return an empty list, a 500 error and a three-second delay on command. This article shows how to intercept fetch and XMLHttpRequest directly in the browser with a JustZix JS rule, returning fake JSON so you can build and test the frontend against any scenario you like.

Why intercept in the browser at all

The usual answer to "I need fake data" is a mock server — MSW, json-server, a stub in your dev environment. Those are great, and for a whole team they are the right call. But sometimes you want something lighter:

A JustZix JS rule lives in the browser, applies by URL pattern, and needs zero project changes. That is its niche: quick, surgical, throwaway-friendly mocking.

Wrapping window.fetch

The core trick is replacing window.fetch with your own function that decides, per request, whether to mock or pass through. Run this at document_start so it is installed before any app code calls fetch:

// Save the original so we can fall through to the real network
const realFetch = window.fetch.bind(window);

// Your mock table: match by URL substring -> response factory
const mocks = [
  {
    match: '/api/user/profile',
    response: () => jsonResponse({ id: 1, name: 'Ada Lovelace', plan: 'pro' })
  },
  {
    match: '/api/orders',
    response: () => jsonResponse([])   // the empty-list edge case
  }
];

window.fetch = async (input, init) => {
  const url = typeof input === 'string' ? input : input.url;
  const hit = mocks.find(m => url.includes(m.match));
  if (hit) {
    console.log('[mock] intercepted', url);
    return hit.response();
  }
  return realFetch(input, init);   // not mocked -> real request
};

Building a synthetic Response

Your app expects a real Response object — it will call .json(), check .ok, read .status. The Response constructor gives you all of that for free:

// Helper: a 200 OK JSON response
function jsonResponse(data, status = 200) {
  return new Response(JSON.stringify(data), {
    status,
    headers: { 'Content-Type': 'application/json' }
  });
}

Because it is a genuine Response, code that does const r = await fetch(...); if (r.ok) return r.json(); works without modification. That is the whole point — the app cannot tell the difference.

Simulating latency

Empty lists and happy paths are easy. The bugs live in the slow path: spinners that never hide, race conditions, double-submits. Add a delay before resolving:

// A small sleep helper
const sleep = (ms) => new Promise(r => setTimeout(r, ms));

// In the mock table, await it before returning
{
  match: '/api/search',
  response: async () => {
    await sleep(3000);            // 3 seconds of "loading..."
    return jsonResponse({ results: [] });
  }
}

Simulating error codes

Now the unhappy paths. A 500, a 404, a 401 that should bounce you to login — return them with the right status so your error handling actually runs:

// 500 Internal Server Error
{
  match: '/api/checkout',
  response: () => jsonResponse(
    { error: 'payment_gateway_timeout' },
    500
  )
}

// A network failure (fetch rejects, not resolves)
{
  match: '/api/flaky',
  response: () => Promise.reject(new TypeError('Failed to fetch'))
}

Note the difference: a 500 still resolves with r.ok === false, while a dropped connection rejects. Test both — apps frequently handle one and crash on the other.

Wrapping XMLHttpRequest

Plenty of older code, and libraries like axios, still use XMLHttpRequest under the hood. fetch interception does not touch them. XHR is clunkier to fake, but a thin wrapper covers the common case:

const RealXHR = window.XMLHttpRequest;

window.XMLHttpRequest = function () {
  const xhr = new RealXHR();
  const realOpen = xhr.open;
  let mockedUrl = null;

  xhr.open = function (method, url, ...rest) {
    if (url.includes('/api/legacy')) mockedUrl = url;
    return realOpen.call(this, method, url, ...rest);
  };

  const realSend = xhr.send;
  xhr.send = function (...args) {
    if (!mockedUrl) return realSend.apply(this, args);

    // Fake a successful response without hitting the network
    setTimeout(() => {
      Object.defineProperty(xhr, 'readyState', { value: 4 });
      Object.defineProperty(xhr, 'status', { value: 200 });
      Object.defineProperty(xhr, 'responseText', {
        value: JSON.stringify({ legacy: true })
      });
      xhr.onreadystatechange && xhr.onreadystatechange();
      xhr.onload && xhr.onload();
    }, 0);
  };

  return xhr;
};

This is deliberately minimal — it covers onload / onreadystatechange with JSON text, which is what 90% of XHR code needs. If a library inspects getAllResponseHeaders() you will need to stub that too. At that point, honestly, reach for a real mock server.

When to use a real mock server instead

Browser interception is the right tool for quick, scoped, throwaway work. Switch to MSW or json-server when:

Rule of thumb: if the mock should be committed, use a server. If it should vanish when you toggle a rule off, use JustZix.

See also

Stop waiting on the backend. Install JustZix, drop a fetch wrapper into a scoped JS rule, and build the frontend against every scenario today.

Rate this post

No ratings yet — be the first.

Try it yourself

Install JustZix and paste any snippet from this article. Two minutes from zero to a working rule across all your devices.

Get JustZix

Features · How it works · Examples · Use cases