Observing Network Requests with webRequest in MV3

Use non-blocking chrome.webRequest listeners in a Manifest V3 service worker: narrow filters, extraHeaders, correlating events by requestId, and per-tab data that survives eviction.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

Your extension needs to know what a page is loading — to count trackers, time API calls, flag mixed content, or show which requests failed — and you have read that MV3 “removed webRequest”. It did not. It removed the ability to block from webRequest; every observational event still fires, with almost all the detail it ever had. What changed is where the listener lives: in a service worker that wakes for every matching request and is evicted between bursts, so a design that worked in a persistent background page now loses data, keeps the worker awake all day, or both. This guide sits under cookies and webRequest observation.

Why observation behaves differently in a service worker

In MV2 a listener was a function in a page that lived as long as the browser. In MV3 each webRequest event is an extension event like any other: if the worker is asleep, the browser starts it, evaluates the script, and dispatches the event to whatever listeners were registered during that first synchronous pass. Three consequences follow. A listener registered after an await misses the event that woke the worker. Module-level state — a Map of requests in flight — is wiped every time the worker is evicted, which happens whenever thirty seconds pass without an event. And because every matching request is an event, an <all_urls> filter on a busy tab means the worker effectively never sleeps, which is exactly the resource cost MV3 was designed to remove.

A worker's life while observing a page loadThe first request wakes the worker, a burst of requests keeps it alive, the page goes idle, the worker is evicted after thirty seconds, and a later request starts a fresh worker with empty memory.page loadlater XHRCold st…~100 msRequest burstevents keep it aliveIdle30 s timerEvictedmemory goneNew workerempty Maplisteners must exist herein-memory state lost
Anything held only in memory between the two bursts is gone by the time the second one arrives.

Step-by-step: an observer that survives MV3

1. Declare the narrowest permissions that work

1{
2  "permissions": ["webRequest", "storage"],
3  "host_permissions": ["https://*.example.com/*"]   // the requests you observe AND the pages they come from
4}

Execution context: the manifest. Chrome only reports a request if the extension has host access to the request URL; for requests initiated by a page, it also needs access to the initiator. "webRequest" itself shows no install warning, but the host permissions do, and <all_urls> triggers extra review. Firefox MV3 treats the hosts as optional until granted.

2. Register listeners at the top level with tight filters

1// sw.js — no await before this point
2const FILTER = {
3  urls: ["https://api.example.com/*"],
4  types: ["xmlhttprequest", "main_frame"],
5};
6
7chrome.webRequest.onBeforeRequest.addListener(onStart, FILTER);
8chrome.webRequest.onCompleted.addListener(onDone, FILTER, ["responseHeaders"]);
9chrome.webRequest.onErrorOccurred.addListener(onError, FILTER);

Execution context: the service worker, during its first synchronous evaluation. types is the cheapest filter you have — excluding image, font and media from an API monitor can cut events by an order of magnitude. Filters are evaluated in the browser process, so a request that does not match never wakes your worker. Safari supports urls but reports a reduced set of types.

3. Correlate events by requestId, persisted per tab

Each request carries a requestId that is stable across all its events, including redirects. Use it to pair a start with its completion, and keep the pairs somewhere eviction cannot reach.

 1const inflight = new Map();     // fast path within one worker lifetime
 2
 3function onStart(d) {
 4  inflight.set(d.requestId, { t0: d.timeStamp, url: d.url, tabId: d.tabId });
 5}
 6
 7async function onDone(d) {
 8  const start = inflight.get(d.requestId);
 9  inflight.delete(d.requestId);
10  if (!start) return;                       // started in a previous worker; skip timing
11  await appendSample(d.tabId, {
12    url: d.url,
13    status: d.statusCode,
14    ms: Math.round(d.timeStamp - start.t0),
15    cached: d.fromCache,
16  });
17}
18
19function onError(d) {
20  inflight.delete(d.requestId);
21  appendSample(d.tabId, { url: d.url, error: d.error });
22}

Execution context: the service worker. Keeping inflight in memory is fine because a request rarely outlives the worker that saw it start — the burst that contains it also keeps the worker awake. The results go to storage in the next step. timeStamp is milliseconds since the epoch with sub-millisecond precision; use it rather than Date.now(), which measures when your listener ran, not when the network event happened.

Pairing start and completion by requestIdonBeforeRequest records the start time under the requestId; a redirect keeps the same id; onCompleted looks up the start, computes duration and appends a sample to session storage.NetworkWorker memorystorage.sessiononBeforeRequest #42t0onBeforeRedirect #42same idonCompleted #42status 200ms = t1 − t0append sample to tab list
The requestId survives redirects, so one entry covers the whole chain.

4. Store results per tab in session storage, with a cap

 1const MAX_PER_TAB = 200;
 2
 3async function appendSample(tabId, sample) {
 4  if (tabId < 0) return;                    // requests from the extension or the browser itself
 5  const key = `net:${tabId}`;
 6  const { [key]: list = [] } = await chrome.storage.session.get(key);
 7  list.push(sample);
 8  if (list.length > MAX_PER_TAB) list.splice(0, list.length - MAX_PER_TAB);
 9  await chrome.storage.session.set({ [key]: list });
10}
11
12chrome.tabs.onRemoved.addListener((tabId) => chrome.storage.session.remove(`net:${tabId}`));

Execution context: the service worker. tabId is -1 for requests not tied to a tab — service-worker fetches, prefetches, the extension’s own calls. chrome.storage.session is capped at 10 MB in Chrome, which is why the per-tab cap matters; a long-lived single-page app can issue thousands of requests in an afternoon. Read-modify-write races between rapid events are possible; if precision matters, batch samples in memory and flush with a short debounce.

5. Read headers the browser normally hides

Some headers — Cookie, Set-Cookie, Referer, Origin — are omitted from webRequest events unless you ask for them.

1chrome.webRequest.onSendHeaders.addListener(
2  (d) => {
3    const origin = d.requestHeaders?.find((h) => h.name.toLowerCase() === "origin")?.value;
4    if (origin && origin !== "https://app.example.com") flagCrossOrigin(d.tabId, d.url, origin);
5  },
6  FILTER,
7  ["requestHeaders", "extraHeaders"]
8);

Execution context: the service worker. extraHeaders is Chrome-specific and costs performance because it forces the network service to route headers through the extension; request it only on the events and filters that need it. Firefox exposes those headers without the flag and ignores extraHeaders. Safari omits many headers regardless.

Cross-browser variation

  • Chrome / Edge: non-blocking listeners only for store-installed extensions; "blocking" throws. extraHeaders is needed for cookie and referrer headers. Requests from other extensions are invisible.
  • Firefox: blocking listeners still work in MV3, so the same code can be extended to modify requests on Firefox only. browser.webRequest.filterResponseData lets Firefox read response bodies — nothing equivalent exists in Chrome.
  • Safari: supports onBeforeRequest, onCompleted, onErrorOccurred and a handful of others for main-frame and subresource requests; header events are limited, and types filtering is coarse. Treat webRequest data from Safari as a best-effort sample.
What each engine reports to an observerComparison of blocking support, header visibility, response body access and request types reported by webRequest in Chrome, Firefox and Safari.CapabilityChromeFirefoxSafariBlocking listenersPolicy installs onlyYesNoCookie / Referer headersWith extraHeadersYesLimitedResponse bodiesNofilterResponseDataNoAll request typesYesYesSubset
Design for Chrome's observational subset and treat the extras as progressive enhancement.

Verification

  1. Load the extension, open a page on the observed host, and open the service worker console.
  2. Run await chrome.storage.session.get(null) — expect a net:<tabId> key with samples whose ms values look plausible.
  3. Stop the worker from chrome://serviceworker-internals, trigger another request on the page, and confirm the list grows rather than resetting.
  4. Open chrome://extensions, click “Inspect views: service worker” and watch the worker status in chrome://serviceworker-internals while the page is idle: it should stop after about thirty seconds. If it never stops, your filter is too broad.
1net:118 → [{url:"https://api.example.com/items", status:200, ms:84, cached:false}, …]

Execution context: the service worker console. A list that resets after an idle period means results are only in memory; a worker that never sleeps means the filter matches more than you need.

FAQ

Can I read the response body?

Not in Chrome or Safari. Firefox’s filterResponseData can. In Chrome, the usual workaround is a main-world content script that wraps fetch for the page’s own requests — with the privacy and performance costs that implies.

Why do I see no events for requests made by my own extension pages?

You do, but with tabId: -1 for service worker requests and the extension’s own origin as the initiator. Requests from other extensions are never visible.

Does observing requests keep the service worker alive?

Each event resets the idle timer, so continuous matching traffic keeps the worker running. That is the main reason to filter narrowly — a quiet filter lets the worker sleep.

Other Core APIs & Cross-Browser Data Management Resources