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:
net.fetch either;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.
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.
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.)
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)
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).
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.)
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.
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
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.
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.
| nock / Node interceptors | MSW | protocol.handle fixtures | json-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 deterministically | stub by hand | stub by hand | write 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.
?mock_snapshot=name) โ parallel Playwright/WebdriverIO workers driving your Electron app each get a stable world./health endpoint.GET /m/YOUR_ID/db.json exports everything in json-server format โ no lock-in.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.