Network Requests & Backend Sync

Talk to your own backend from an MV3 extension: cross-origin fetch with host permissions, CORS from content scripts, WebSockets and push in a service worker, streaming, offline queues and sync.

Most extensions are clients of a server: they fetch a user’s settings, post captured data, sync state across devices, or stream answers from an API. In MV2 that code lived in a background page that never slept, could use XMLHttpRequest, and held a WebSocket open for days. In MV3 the same code runs in a service worker that may be terminated between any two events, has no XMLHttpRequest, and is killed if a single fetch waits too long for a response. Requests from content scripts, meanwhile, are now subject to the page’s CORS rules rather than the extension’s. This topic sits inside Core APIs & Cross-Browser Data Management and starts with the rule that trips up most migrations: making cross-origin fetch requests from an extension works from the worker and fails from content scripts.

The patterns here are the ones that hold up when the worker can disappear at any moment: send requests from the worker, persist anything you are waiting on, treat the network as an event source rather than a connection you own, and design sync around the assumption that half of all attempts happen while the user is offline or the worker is cold.

Where network calls should originateContent scripts and extension pages send intent to the service worker, which holds host permissions and performs fetch, WebSocket and push work against the backend, persisting results to storage.Content scriptpage CORS appliesPopup / side panelshort-livedService workerhost permissionsfetch · WebSocket · pushYour APIhttps://api.acme.examplestorage.localresults + queuestorage.onChangedUI refresh
Route every backend call through the worker — it is the one context exempt from page CORS and present for every event.

Prerequisites checklist

  • host_permissions for every API origin the extension calls, so the worker’s requests are exempt from CORS.
  • A Content Security Policy for extension pages whose connect-src (if you set one) includes those origins.
  • A message contract that lets content scripts and pages ask the worker to make requests on their behalf.
  • A persistent outbox in chrome.storage.local or IndexedDB for writes that must survive worker termination and offline periods.
  • An alarm schedule for periodic sync, because setInterval does not survive eviction.
  • Authentication tokens stored and refreshed as described in refreshing and storing access tokens securely.

Manifest registration

 1{
 2  "manifest_version": 3,
 3  "name": "Acme Clipper",
 4  "version": "3.1.0",
 5  "background": { "service_worker": "sw.js", "type": "module" },
 6  "permissions": [
 7    "storage",              // outbox, sync cursor, cached results
 8    "alarms"                // periodic sync and retries
 9  ],
10  "host_permissions": [
11    "https://api.acme.example/*",        // CORS-exempt fetch from the worker
12    "wss://realtime.acme.example/*"      // WebSocket endpoint (scheme matters)
13  ],
14  "content_security_policy": {
15    "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://api.acme.example wss://realtime.acme.example"
16  }
17}

Execution context: parsed at install. Host permissions on your own API produce an install warning naming the domain; that is usually acceptable for a first-party backend. The connect-src directive is optional — the default MV3 policy does not restrict it — but if you add one, every endpoint must be listed or fetches from extension pages fail with a CSP error. Firefox treats host permissions as optional in MV3 and may need a runtime grant before the first request succeeds.

1. Fetching from the service worker

The worker has fetch, Request, Response, Headers, AbortController, URL and streams. It does not have XMLHttpRequest, window, or document. With host permission for the target origin, the worker’s requests skip CORS preflight checks entirely — the response is readable even if the server sends no Access-Control-Allow-Origin header.

 1// sw.js
 2export async function api(path, { method = "GET", body, signal } = {}) {
 3  const token = await getAccessToken();
 4  const res = await fetch(`https://api.acme.example${path}`, {
 5    method,
 6    signal,
 7    headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
 8    body: body ? JSON.stringify(body) : undefined,
 9  });
10  if (res.status === 401) throw Object.assign(new Error("unauthorised"), { retry: "refresh" });
11  if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { status: res.status });
12  return res.status === 204 ? null : res.json();
13}

Execution context: the service worker. Every pending fetch counts as extension activity, but Chrome terminates a worker whose fetch takes longer than about thirty seconds to start returning a response, so slow endpoints need either streaming or a job-and-poll design. Firefox’s MV3 background (an event page by default) has the same CORS exemption with host permissions. Safari honours host permissions for CORS but enforces its own stricter connection limits.

Who is subject to CORS?Whether fetch from the service worker, extension pages, content scripts and main-world scripts is exempt from CORS when the extension holds host permission for the target.ContextCORS exemptOrigin sentTypical useService workerYes, with host permissionchrome-extension://idAll API callsPopup / optionsYes, with host permissionchrome-extension://idDirect UI fetchesContent scriptNo (Chrome 85+)Page originProxy via workerMain-world scriptNoPage originPage's own calls
Host permissions lift CORS for extension contexts only — content scripts inherit the page's origin.

2. Proxying content script requests

A content script that needs data from your API sends a message; the worker validates it and makes the call. The validation matters: content scripts run inside pages you do not control, and a compromised renderer can send any message your listener accepts. Never expose a generic “fetch this URL” message.

1// sw.js
2chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
3  if (msg?.type !== "lookup" || typeof msg.term !== "string" || msg.term.length > 200) return;
4  if (!sender.tab) return;                               // only from content scripts
5  api(`/v1/lookup?q=${encodeURIComponent(msg.term)}`)
6    .then((data) => sendResponse({ ok: true, data }))
7    .catch((err) => sendResponse({ ok: false, error: err.message }));
8  return true;                                           // async response
9});

Execution context: the service worker. The message names an operation (lookup) with typed arguments, not a URL, so the worker decides exactly which endpoint is called. Returning true keeps the channel open for the async reply. The same pattern in Firefox and Safari works unchanged with browser.runtime.onMessage returning a promise. The security rationale is covered in treating content script messages as untrusted.

3. Real-time channels: WebSockets and push

Real-time updates are where the service worker model bites hardest. A WebSocket held open in a worker that is about to be evicted is closed with it. Chrome 116 and later count WebSocket traffic as activity — each message sent or received resets the idle timer — so a socket with a heartbeat every twenty seconds keeps the worker alive. That is appropriate while the user is actively using a real-time feature and wasteful otherwise.

1let ws;
2export function openRealtime() {
3  ws = new WebSocket("wss://realtime.acme.example/v1/stream");
4  const beat = setInterval(() => ws.readyState === 1 && ws.send('{"type":"ping"}'), 20_000);
5  ws.onmessage = (e) => handleEvent(JSON.parse(e.data));
6  ws.onclose = () => { clearInterval(beat); ws = null; };
7}

Execution context: the service worker, Chrome 116+. The twenty-second heartbeat sits safely inside the thirty-second idle window. Firefox’s event-page background can hold sockets but is also suspended when idle; Safari’s background is aggressively suspended and sockets do not reliably survive. For anything not user-initiated, server push is the better fit — it wakes the worker only when there is something to say. Details for both are in using WebSockets from an MV3 service worker and receiving server push in an extension.

Worker uptime per day by real-time strategyApproximate hours per day the service worker stays alive for a user receiving about forty updates a day, under polling every minute, a permanent WebSocket, a WebSocket only while the side panel is open, and web push.Permanent WebSocket24 hours/dayPolling every minute9.6 hours/daySocket while panel open1.5 hours/dayWeb push0.4 hours/day
Push keeps the worker asleep until there is news; a permanent socket keeps it awake all day.

4. Streaming long responses

AI and search APIs that stream their answers are a natural fit for fetch with a readable body. Reading the stream keeps the request active, and each chunk can be forwarded to the UI as it arrives.

 1export async function streamAnswer(prompt, port) {
 2  const res = await fetch("https://api.acme.example/v1/answer", {
 3    method: "POST",
 4    headers: { "Content-Type": "application/json" },
 5    body: JSON.stringify({ prompt, stream: true }),
 6  });
 7  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
 8  for (;;) {
 9    const { value, done } = await reader.read();
10    if (done) break;
11    port.postMessage({ type: "chunk", text: value });
12  }
13  port.postMessage({ type: "done" });
14}

Execution context: the service worker, with port being a long-lived connection from the side panel or popup. As long as bytes keep arriving the fetch is active and the worker stays up; a server that pauses for more than thirty seconds mid-stream risks termination. Firefox and Safari support streaming bodies and TextDecoderStream in recent versions. Parsing server-sent-event framing and cancelling cleanly when the panel closes are covered in streaming responses with fetch in the service worker.

5. Sync that survives eviction and offline

Writes the user makes — a clipped article, a toggled setting — must reach the server even if the worker dies a moment later or the laptop is offline. The durable pattern is an outbox: append the write to storage first, try to send it, and remove it only after the server confirms. An alarm retries whatever remains.

1export async function enqueue(op) {
2  const { outbox = [] } = await chrome.storage.local.get("outbox");
3  outbox.push({ ...op, id: crypto.randomUUID(), queuedAt: Date.now() });
4  await chrome.storage.local.set({ outbox });
5  flush();                                    // best effort now; the alarm covers failure
6}
7
8chrome.alarms.create("flush-outbox", { periodInMinutes: 5 });
9chrome.alarms.onAlarm.addListener((a) => a.name === "flush-outbox" && flush());

Execution context: the service worker. Generating the id client-side lets the server deduplicate a write that was sent successfully but whose confirmation was lost when the worker terminated. The alarm must be created at the top level or in onInstalled; its listener must be top-level so it can wake the worker. Firefox and Safari support the same alarm and storage calls, with Safari delaying alarms more aggressively when the system is idle. The full design is in syncing extension data with a backend API and handling offline and retrying requests.

An outbox write that survives terminationThe popup sends a write to the worker, which appends it to the outbox in storage, attempts to send it, is terminated before the response, and a later alarm resends it; the server deduplicates by id.PopupService workerstorage.localAPIsave clipoutbox.push({id})POST /clips (id)terminated before replyalarm: POST /clips (same id)200 (deduplicated)remove from outbox
Persist before you send, and let the server deduplicate — then termination is harmless.

6. Timeouts and cancellation

fetch has no default timeout. In a web page that is an annoyance; in a service worker it is a trap, because a request hanging on a dead connection keeps an event alive until the browser’s own limits intervene, and your code never learns why the work stopped. Give every request an explicit deadline shorter than the worker’s limits, and cancel requests whose results nobody is waiting for any more — a popup that closed, a tab that navigated away.

 1export async function apiWithDeadline(path, ms = 15_000, outer) {
 2  const signal = outer
 3    ? AbortSignal.any([outer, AbortSignal.timeout(ms)])
 4    : AbortSignal.timeout(ms);
 5  try {
 6    return await api(path, { signal });
 7  } catch (err) {
 8    if (err.name === "TimeoutError") throw Object.assign(new Error("timeout"), { retry: "later" });
 9    throw err;
10  }
11}
12
13// cancel when the requesting popup disconnects
14chrome.runtime.onConnect.addListener((port) => {
15  const ctrl = new AbortController();
16  port.onDisconnect.addListener(() => ctrl.abort());
17  port.onMessage.addListener((m) => apiWithDeadline(m.path, 15_000, ctrl.signal)
18    .then((data) => port.postMessage({ data }))
19    .catch(() => {}));
20});

Execution context: the service worker. AbortSignal.timeout throws a TimeoutError, distinct from the AbortError a manual cancel produces, which lets you retry the former and silently drop the latter. AbortSignal.any is available in Chrome 116+, Firefox 124+ and Safari 17.4+; on older engines, chain the signals manually. Fifteen seconds is a sensible default for interactive calls — comfortably inside the thirty-second response window, and long enough for a slow mobile connection.

Cancellation also matters for correctness. A search-as-you-type feature that does not cancel stale requests can render the answer to “ab” after the answer to “abc”, because responses do not arrive in the order requests were sent. Abort the previous request whenever a new one supersedes it.

Cross-cutting concerns: permissions, security and privacy

Host permissions on your API are the price of CORS-free requests from the worker, and they are almost always worth paying: the alternative is configuring your server to accept the chrome-extension:// origin, which breaks the moment your id changes between builds, and does nothing for Firefox’s random per-install UUID origins. Scope the permission to the API hostname rather than a wildcard over your whole domain, so a compromise of a marketing subdomain cannot be used to feed the extension data.

Never ship long-lived secrets in the bundle — anything in the package can be extracted in seconds. Use per-user tokens obtained through sign-in, as described in handling API keys without shipping them in the bundle. Treat every response as untrusted data: a server compromise should not become script execution in your extension, which means no innerHTML with response fields and no evaluation of returned code, which MV3 forbids in any case.

Privacy policies must describe what is sent. If the extension sends page URLs, selected text, or anything the user did not explicitly submit, the store listing must disclose it, and reviewers check that the code matches the disclosure.

Choosing where each request runs

A useful rule of thumb: requests that must finish regardless of which UI is open belong in the service worker; requests that only feed what the user is looking at can run in the popup or side panel; requests that need the page’s cookies or origin belong in the page itself, never in the extension. Writing that decision down for each endpoint — owner context, retry policy, and what the UI shows while it is pending — prevents the most common class of sync bug, where a request started in the popup dies silently when the popup closes.

MV3 constraints box

  • No XMLHttpRequest in the worker. Use fetch; libraries that depend on XHR (older Axios adapters, some SDKs) must be configured for fetch.
  • Slow responses terminate the worker. A fetch whose response does not start within about thirty seconds can be cut off; use streaming or job-and-poll for slow endpoints.
  • Content scripts follow page CORS. Since Chrome 85, cross-origin fetches from content scripts are treated as the page’s — proxy them through the worker.
  • Sockets need traffic. WebSockets keep the worker alive only while messages flow (Chrome 116+); idle sockets die with the worker.
  • Timers do not survive eviction. Use chrome.alarms (minimum one minute in packed builds) for periodic sync and retries.
  • Five-minute ceiling per event. A single event’s work, including the requests it awaits, should finish well within five minutes.

Cross-browser notes

CapabilityChrome / EdgeFirefoxSafari
CORS exemption with host permissionWorker and extension pagesBackground and extension pagesYes, with granted hosts
Content script cross-origin fetchPage CORS (85+)Extension privileges with host permissionPage CORS
XMLHttpRequest in backgroundNo (service worker)Yes in event pageNo (service worker)
WebSocket extends lifetimeYes, 116+Event page idle rulesUnreliable
Web Push / chrome.gcmPush API (121+), gcmPush APINot for extensions
Streaming fetch bodiesYesYesYes (recent)

Firefox is notably more permissive for content scripts: with host permission, a content script’s fetch runs with extension privileges. Do not rely on it — a design that proxies through the background works in every engine.

What this section covers

The guides take one problem each. Making cross-origin fetch requests from an extension settles the CORS rules. Using WebSockets from an MV3 service worker and receiving server push in an extension cover the two real-time options. Streaming responses with fetch in the service worker handles long answers. Syncing extension data with a backend API and handling offline and retrying requests make writes durable.

Deliberately elsewhere: observing other sites’ traffic is cookies and webRequest observation; talking to a local desktop program is native messaging and host integration; and obtaining the tokens these requests carry is identity and OAuth authentication.