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.
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.
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.
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.extraHeadersis 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.filterResponseDatalets Firefox read response bodies — nothing equivalent exists in Chrome. - Safari: supports
onBeforeRequest,onCompleted,onErrorOccurredand a handful of others for main-frame and subresource requests; header events are limited, andtypesfiltering is coarse. Treat webRequest data from Safari as a best-effort sample.
Verification
- Load the extension, open a page on the observed host, and open the service worker console.
- Run
await chrome.storage.session.get(null)— expect anet:<tabId>key with samples whosemsvalues look plausible. - Stop the worker from
chrome://serviceworker-internals, trigger another request on the page, and confirm the list grows rather than resetting. - Open
chrome://extensions, click “Inspect views: service worker” and watch the worker status inchrome://serviceworker-internalswhile 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.
Related
- Handling auth challenges with webRequestAuthProvider — the one blocking hook MV3 kept.
- Migrating webRequest to declarativeNetRequest step by step — where the blocking half went.
- Throttling and debouncing high-frequency events — keeping a busy observer cheap.
- Cookies and webRequest observation — the parent topic.