โ† All guides

Mock a REST API for Electron โ€” net.fetch, the renderer CORS trap, and why nock can't see half your app

An Electron app does HTTP from two very different worlds: the main process (Node + Chromium's network stack) and the renderer (a browser page, with browser rules). That split is exactly where API mocking goes wrong:

A hosted mock API sidesteps the split entirely: it's a real https URL, so it looks identical to production from the main process, the renderer, a packaged build on a tester's machine, and CI. Every snippet below was run verbatim with Electron 44 against the live endpoints on this page before publishing โ€” the printed outputs are from those runs.

1. Try it in 10 seconds (no signup)

A shared, self-resetting demo project is live right now:

curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"

Or make it your own project in one click โ€” no terminal needed.

2. Fetch from the main process with net.fetch

In the main process the cleanest client is Electron's own net.fetch โ€” the standard fetch API, but routed through Chromium's network stack, so it honors the system proxy and certificate store the way the rest of your app does. Sorting, filtering and pagination are query params on the mock:

// main.js โ€” fetch from the Electron main process
const { app, net } = require("electron");

const BASE = "https://mockbird.mockbird.workers.dev/m/demo";

app.whenReady().then(async () => {
  const res = await net.fetch(`${BASE}/products?limit=3&sortBy=price&order=desc`);
  const products = await res.json();
  console.log(res.status, "total:", res.headers.get("x-total-count"));
  for (const p of products) console.log(`- ${p.name}: $${p.price}`);
  app.quit();
});
200 total: 30
- Car Week: $966.08
- Power Child Friend Thing Stone: $923.34
- Night Friend Month Cloud Member: $919.98

Every list endpoint sends X-Total-Count, so pagination UIs know how many pages exist without a second call. (The plain global fetch() also works in main โ€” it's Node's fetch there. The difference between the two matters a lot for mocking; see ยง6.)

3. The renderer CORS trap: works packaged, breaks in dev

Calling an API directly from your renderer looks like it works โ€” until it doesn't, and when it breaks depends on how the window was loaded. Both halves of this are verified, same code, Electron 44:

// renderer: fetch() obeys browser rules โ€” CORS applies here
const res = await fetch("https://example.com/");   // no CORS headers on that server

Page loaded with win.loadFile("index.html") (how packaged apps usually run) โ€” Electron exempts file:// pages from CORS, so it succeeds:

example.com ok: 200

Same fetch with the page served from a local dev server (win.loadURL("http://localhost:5199/") โ€” how vite/webpack dev mode runs) โ€” normal browser CORS kicks in:

Access to fetch at 'https://example.com/' from origin 'http://localhost:5199'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is
present on the requested resource.
example.com FAILED: Failed to fetch

So the API call that worked in your packaged build fails on every teammate's dev setup โ€” or the reverse. The tempting "fix", webSecurity: false, is on Electron's own security checklist of things not to do. Two honest ways out:

const res = await fetch("https://mockbird.mockbird.workers.dev/m/demo/products?limit=2");
const data = await res.json();
console.log("mockbird ok:", res.status, data.length + " records");
// โ†’ mockbird ok: 200 2 records   (file:// AND http://localhost)

4. The pattern Electron recommends: preload + IPC

Production Electron apps keep network and secrets in the main process and hand the renderer a narrow bridge: contextBridge in a preload script, ipcRenderer.invoke โ†’ ipcMain.handle. Here's the whole pattern, working against the mock โ€” with context isolation on (the default):

// main.js
const { app, BrowserWindow, ipcMain, net } = require("electron");
const path = require("path");

const BASE = "https://mockbird.mockbird.workers.dev/m/demo";

ipcMain.handle("api:get", async (_e, resource, params = {}) => {
  const qs = new URLSearchParams(params).toString();
  const res = await net.fetch(`${BASE}/${resource}${qs ? "?" + qs : ""}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
});

app.whenReady().then(() => {
  const win = new BrowserWindow({
    width: 900, height: 600,
    webPreferences: { preload: path.join(__dirname, "preload.js") },
  });
  win.loadFile("index.html");
});
// preload.js
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("api", {
  get: (resource, params) => ipcRenderer.invoke("api:get", resource, params),
});
<!-- index.html โ€” the renderer never fetches; no CORS, no exposed URLs -->
<ul id="list"></ul>
<script>
(async () => {
  const products = await window.api.get("products", { limit: 3, sortBy: "price", order: "desc" });
  for (const p of products) {
    const li = document.createElement("li");
    li.textContent = `${p.name} โ€” $${p.price}`;
    document.getElementById("list").appendChild(li);
  }
})();
</script>

Verified output (renderer console): rendered 3 products via IPC. Because BASE lives in one place in main, pointing the whole app at the mock โ€” or back at production โ€” is a one-line change (or an environment variable).

5. Writes are real

The mock is stateful: a settings panel or sync feature can create records and read them back โ€” unlike the classic fake APIs that return {"id": 101} and forget you existed:

const created = await (await net.fetch(`${BASE}/products`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ name: "Desktop App Widget", price: 12.5, inStock: true }),
})).json();
console.log("created id:", created.id);

const back = await (await net.fetch(`${BASE}/products/${created.id}`)).json();
console.log("read back:", back.name, back.price);
created id: 31
read back: Desktop App Widget 12.5
deleted: 200

(The run deletes its record afterwards โ€” PUT, PATCH and DELETE are all real too.)

6. Why nock can't mock this app

This is the part that surprises people. nock works by patching Node's networking, so in an Electron app it has two blind spots: everything in the renderer (a different process speaking Chromium's network stack), and net.fetch in main (same Chromium stack, never touches Node's http module). Demonstrated, not asserted โ€” one interceptor, two fetches, Electron 44 + nock 14:

const nock = require("nock");
nock("https://mockbird.mockbird.workers.dev")
  .get(/.*/)
  .reply(200, [{ name: "FROM NOCK" }]);

// Chromium's network stack โ€” the interceptor never sees it:
const viaNet = await (await net.fetch(BASE + "/products?limit=1")).json();
console.log("net.fetch got:", viaNet[0].name);

// Node's fetch (undici) โ€” nock 14 catches this one:
const viaFetch = await (await fetch(BASE + "/products?limit=1")).json();
console.log("global fetch got:", viaFetch[0].name);
net.fetch got: World End
global fetch got: FROM NOCK

Same URL, same process, opposite results โ€” your test suite passes or fails depending on which fetch a refactor picked. A hosted mock doesn't care: it's reached over real HTTP by every client โ€” net.fetch, global fetch, axios, the renderer โ€” with no interception layer to keep in sync.

7. Failure drills: retry, timeout, rate limit

net.fetch never retries for you. Rehearse your backoff logic against a deterministic sequence โ€” mock_seq=500,500,200 fails twice then succeeds, and a fresh mock_seq_key per run keeps sequences independent:

async function fetchWithRetry(url, opts = {}, tries = 3) {
  for (let attempt = 1; ; attempt++) {
    const res = await net.fetch(url, opts);
    if (res.ok || attempt >= tries || res.status < 500) return res;
    const wait = 500 * 2 ** (attempt - 1); // 500ms, 1s, 2sโ€ฆ
    console.log(`attempt ${attempt} โ†’ ${res.status}, retrying in ${wait}ms`);
    await new Promise((r) => setTimeout(r, wait));
  }
}

const seq = (key) =>
  `${BASE}/products?limit=1&mock_seq=500,500,200&mock_seq_key=${key}`;

const plain = await net.fetch(seq(crypto.randomUUID()));
console.log("no retry:", plain.status);

const res = await fetchWithRetry(seq(crypto.randomUUID()));
console.log("with retry:", res.status);
no retry: 500
attempt 1 โ†’ 500, retrying in 500ms
attempt 2 โ†’ 500, retrying in 1000ms
with retry: 200

Timeouts โ€” a desktop app on hotel wifi needs them. net.fetch accepts a standard AbortSignal; test it against an endpoint that genuinely takes 3 seconds:

const t0 = Date.now();
try {
  await net.fetch(`${BASE}/products?mock_delay=3000`, {
    signal: AbortSignal.timeout(2000),
  });
} catch (e) {
  console.log(`${e.name} after ${Date.now() - t0}ms`);  // โ†’ TimeoutError after 2002ms
}

Rate limits โ€” mock_ratelimit=3 allows 3 requests per minute for your key, then answers real 429s with a Retry-After header, so you can verify your client actually reads it:

const key = crypto.randomUUID();
const url = `${BASE}/products?limit=1&mock_ratelimit=3&mock_ratelimit_key=${key}`;
for (let i = 1; i <= 4; i++) {
  const res = await net.fetch(url);
  console.log(`call ${i}: ${res.status}` +
    (res.status === 429 ? ` retry-after: ${res.headers.get("retry-after")}s` : ""));
}
call 1: 200
call 2: 200
call 3: 200
call 4: 429 retry-after: 52s

8. Mock a login flow (real JWTs)

Building a login window? Every project has auth endpoints that accept any email + password and return a real signed JWT โ€” store it (in main, please, not localStorage) and send it as a Bearer token:

const { token } = await (await net.fetch(`${BASE}/auth/login`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ email: "dev@example.com", password: "hunter2" }),
})).json();

const me = await (await net.fetch(`${BASE}/auth/me`, {
  headers: { authorization: `Bearer ${token}` },
})).json();
console.log("me:", me.user.email);   // โ†’ me: dev@example.com

Tokens expire on a schedule you control (5sโ€“7d), so you can rehearse the refresh path; the auth guide covers expiry drills and protected mode, where every endpoint 401s without a token.

9. Your own project

The shared demo resets daily. Your own project โ€” with your own schema โ€” is one curl (or one click):

curl -s -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H "content-type: application/json" -d '{"preset":"ecommerce"}'

The response includes your base URL and an admin key. Define custom resources and field types in the dashboard or via API, import an OpenAPI spec, a db.json or a CSV, and point BASE at it.

10. Honest comparison: the Electron options

nock / Node interceptorsMSWprotocol.handle fixturesjson-server (localhost)hosted mock (Mockbird)
Covers renderer requestsโœ— (different process)โœ” via service worker โ€” but service workers need http(s), not file://โœ” if you fetch the custom schemeโœ”โœ”
Covers net.fetch in mainโœ— โ€” demonstrated in ยง6โœ— (Node interceptors, same blind spot)โœ— (http(s) URLs bypass it)โœ”โœ”
Works offline / in unit testsโœ” โ€” its home turfโœ”โœ”โœ” (local process)โœ— โ€” needs network
Same URL in dev, CI and a packaged build on another machineโœ—โœ—app-only schemeโœ— (localhost)โœ” โ€” it's a URL
Inject 500s / 429s / delays deterministicallystub by handstub by handwrite handlersโœ—one query param
Persistent CRUD + filters + auth for freeโœ—โœ—โœ—CRUD yes, auth noโœ”

Be clear-eyed: for fast offline unit tests of main-process modules, nock (for Node http / global fetch) or MSW remain the right tools โ€” just know which network stack each request uses, or a green suite can hide a request that never got intercepted. Use the hosted mock for what interceptors can't do: integration tests through the app's real networking, one dataset shared by the renderer, main, CI and a packaged build, and failure drills against real status codes over the wire.

11. Useful extras

Related: mock APIs for Node.js, mock APIs for Chrome extensions (the other place CORS surprises people), mock APIs for React, testing loading and error states, mock JWT auth, deterministic test data with snapshots, and free mock-API tools compared.

Verification: every snippet on this page was run verbatim with Electron 44.4.3 + nock 14 (Linux, Node 22) against production on 22 Sep 2026 โ€” the printed outputs shown are from those runs, including both CORS branches of ยง3 (same page loaded via loadFile and via a localhost dev server) and the nock demonstration in ยง6. The ยง5 record was deleted after the run. Demo data reseeds daily, so names, prices and ids will differ when you run it. If a snippet here doesn't work in your app, that's a bug: tell us.