Handling Fetch Events in Service Workers¶
The fetch event is how a service worker sits between your pages and the network: every request a controlled page makes, including the navigation itself, scripts, images and fetch() calls, can be dispatched to your worker as a FetchEvent, and whatever Response you pass to event.respondWith() is what the page receives. Getting it right means knowing exactly which requests arrive, what the Request object tells you, the strict rules respondWith() enforces, and which responses the browser will turn into network errors. This page covers all of it at the level of the specifications and browser source code, and ends with a complete, dependency-free router you can adapt.
Key takeaways
- Call
respondWith()synchronously inside the listener, at most once, with aResponseor a promise for one. A rejected promise, a non-Responsevalue or a disallowed response type becomes a network error for the page. - If you do not call
respondWith(), the request goes to the network as if no service worker existed. The worker still had to start, so a fetch handler that mostly falls through is pure overhead. event.requestcarriesmode,destination,credentials,cache,redirect,integrityand more.fetch(event.request)preserves them;fetch(event.request.url)throws them away.- Opaque responses (cross-origin
no-cors) are unreadable, can only answerno-corsrequests, and Chromium adds a pseudo-random quota padding of 0 to about 14 MiB to each one (about 7 MiB on average). - Navigations use redirect mode
manual, so a cached response withredirected === truecannot answer them. Copy it into a cleanResponsefirst. - The Cache API ignores
Rangeheaders and refuses to store206responses, so serving cached audio and video means building the206yourself. - Match routes synchronously on
mode,destination,methodandURLPattern, then do all asynchronous work inside the promise you pass torespondWith().
How a request reaches your fetch handler¶
The Fetch standard hands every request whose service-workers mode is "all" to the Service Workers specification's Handle Fetch algorithm before touching the network. Handle Fetch decides which registration (if any) gets the request, starts the worker if needed and dispatches the FetchEvent. What happens next depends on whether the request is a navigation or a subresource.
Navigations and subresources take different paths¶
The spec splits requests into two groups:
- Non-subresource requests
- Requests that create a new client: document navigations (
destinationdocument,iframe,frame) and the script requests for dedicated and shared workers. For these, the browser matches the request URL against registration scopes at that moment. If a registration with an active worker matches, that worker becomes the new client's controller, receives thefetchevent, and a soft update check runs in the background (see Updating Service Workers). - Subresource requests
- Everything a client requests after it exists: scripts, stylesheets, images, fonts,
fetch(),XMLHttpRequest,EventSourceand so on. These go to the client's active service worker, the one that was assigned when the client was created (or later throughclients.claim()). Scope is never re-evaluated, which is why a page that loaded before your worker activated stays uncontrolled until it reloads or you claim it (see Lifecycle).
A direct consequence: cross-origin subresources go through your worker. When a controlled page loads an image from a CDN or a script from an analytics domain, your fetch handler sees that request. A cross-origin iframe is different: its navigation is matched against the scopes of the iframe's own origin (in a partitioned storage key when third-party), so your worker never sees it or anything inside it.
sequenceDiagram
participant Page as Controlled page
participant UA as Browser fetch
participant SW as Service worker
participant Net as Network or HTTP cache
Page->>UA: GET /api/items
UA->>UA: "service-workers mode is all?"
UA->>SW: start worker if not running
UA->>SW: dispatch FetchEvent
alt respondWith called
SW-->>UA: Response or promise
UA->>UA: validate type, redirect, body
UA-->>Page: response or network error
else no respondWith
UA->>Net: perform the request normally
Net-->>Page: response
end Requests that never reach the fetch event¶
Knowing what bypasses your handler is as important as knowing what reaches it:
| Request | Why it skips the fetch event |
|---|---|
<embed> and <object> loads | Handle Fetch returns early for the embed and object destinations. |
| Navigations started with shift+reload (hard reload) | The spec returns early; the resulting page is uncontrolled, so its subresources skip the worker too. |
| Requests made by the service worker itself | fetch() called from a ServiceWorkerGlobalScope sets service-workers mode to "none", so your handler never recurses into itself. |
The worker script, importScripts() and update checks | Service worker script fetches are never intercepted. |
| The navigation preload request | Its service-workers mode is set to "none" (see Navigation Preload). |
| Individual redirect hops of a subresource | With redirect mode follow, the Fetch standard sets service-workers mode to "none" for network redirects: "Redirects coming from the network (as opposed to from a service worker) are not to be exposed to a service worker." You only see the original request. |
Requests matched by a static route with source network or cache | The browser answers them without starting your worker (see Static Routing API). |
Workers without a fetch listener, and Chromium's no-op listeners | See The cost of a fetch handler. |
fetchLater() deferred requests (Chromium 135+) | The Fetch standard's queue a deferred fetch sets service-workers mode to "none", so a beacon sent with fetchLater() goes straight to the network. |
FedCM (webidentity) and service worker script requests | The Fetch standard notes that the webidentity and serviceworker destinations are not reflected in RequestDestination because fetches with them "skip service workers". |
| WebSocket and WebTransport connections | Their request modes are internal and never exposed to service workers. |
| Pages outside every scope, and non-secure contexts | No registration matches, so there is no worker to dispatch to. |
What happens between the request and your listener¶
The Handle Fetch algorithm and its helper, Create Fetch Event and Dispatch, run these steps in order. Knowing the order explains several behaviors that otherwise look random:
- Pick the registration. For a non-subresource request, match the URL against scopes (and bail out for
embed/object, non-secure contexts and shift+reload). For a subresource, use the client's active worker, or bail out if it has none. - Compute
shouldSoftUpdate. True for every non-subresource request, and for a subresource request when the registration is stale: its last update check was more than 86,400 seconds (24 hours) ago. - Evaluate static routes, if the active worker registered any. A
networkorcachematch returns before your code is involved;race-network-and-fetch-handlerstarts its own network request. - Start the navigation preload request, if the request is a
GETnavigation, the worker handlesfetch, its listeners are not all empty, and preload is enabled (see Navigation Preload). Note that this check happens before step 6, so a navigation that arrives while the worker is still activating is judged by whatever the preload flag was at that moment. - Skip the event entirely if the worker has no
fetchlistener (the Should Skip Event algorithm), or if all itsfetchlisteners are empty. In the second case the worker is still started in the background so the soft update can run. - Wait for activation. If the active worker's state is
activating, wait until it isactivated. A slowactivatehandler therefore delays every request the new worker controls. - Run the worker if it is not running (the expensive part; see the cost section).
- Queue a task on the handle fetch task source that creates the
FetchEvent, including a freshAbortControllerwhose signal becomesevent.request.signal, and dispatches it to everyfetchlistener in registration order until one callsrespondWith(). - Wait for the response, then run the soft update in parallel if
shouldSoftUpdateis true.
Anatomy of a FetchEvent¶
The current Service Workers specification defines the interface like this:
[Exposed=ServiceWorker]
interface FetchEvent : ExtendableEvent {
constructor(DOMString type, FetchEventInit eventInitDict);
[SameObject] readonly attribute Request request;
readonly attribute Promise<any> preloadResponse;
readonly attribute DOMString clientId;
readonly attribute DOMString resultingClientId;
readonly attribute DOMString replacesClientId;
readonly attribute Promise<undefined> handled;
undefined respondWith(Promise<Response> r);
};
| Member | What it gives you | Notes |
|---|---|---|
request | The Request the browser wants to make | Headers are immutable; clone before reading a body. |
respondWith(r) | Takes over the response | Synchronous, once, Response or promise. |
waitUntil(p) | Inherited from ExtendableEvent; keeps the worker alive for p | Use for cache writes and logging after responding. |
preloadResponse | Promise for the navigation preload response, or undefined | Only for GET navigations with preload enabled. |
clientId | ID of the client that made the request | Empty string when there is no initiating client. |
resultingClientId | ID of the client this request will create | Only for navigations and worker scripts. |
replacesClientId | ID of the client being replaced by a navigation | In the spec's IDL, but no current browser exposes it. |
handled | Promise that settles when the browser has the outcome | Rejects with NetworkError if handling failed. |
The legacy event.isReload attribute was never standardized: Firefox removed it in version 74, Safari never had it, and Chromium still exposes it only for compatibility. Use request.cache or the Chromium-only request.isHistoryNavigation if you need navigation hints.
clientId, resultingClientId and replacesClientId¶
The three ID attributes tell you who is asking and what the request will create:
| Request | clientId | resultingClientId |
|---|---|---|
Subresource from a page (<img>, fetch()) | That page's client ID | "" |
| Subresource from a dedicated worker | The worker's client ID | "" |
| Top-level navigation from the address bar | "" (no initiating client) | ID of the new document's environment |
| Navigation triggered by a link in another tab | Depends on the initiator; treat as unreliable | ID of the new document's environment |
new Worker("/w.js") script request | The creating page's ID | ID of the new worker's environment |
Requests with destination report | Varies | Always "" |
resultingClientId is the reliable handle for "the page this navigation is about to create". Pass it to clients.get() to message the new page, but never wait for it inside the promise you pass to respondWith(). The spec's clients.get() waits for the target client to become execution ready, and a document cannot become execution ready until it has received the response you are still computing. Awaiting it there hangs the navigation until the browser gives up on the event.
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return;
event.respondWith(handleNavigation(event)); // never awaits the client
// Separate lifetime promise: runs after the document exists.
event.waitUntil(
(async () => {
if (!event.resultingClientId) return;
const client = await self.clients.get(event.resultingClientId);
// Messages are queued until the page's client message queue is enabled
// (onmessage set, startMessages() called, or DOMContentLoaded).
client?.postMessage({ type: "SW_VERSION", version: "2026-09-25" });
})(),
);
});
replacesClientId still appears in the IDL, and the spec initializes it from the navigation request's replaces client id, but no engine ships it. Safari exposed an earlier, non-standard spelling, targetClientId, from Safari 11.1 until Safari 16; MDN's browser-compat-data removed the entry in October 2024 after its collector confirmed that no browser supports it, and MDN removed the reference page in September 2026. Treat it as absent: to learn which page a navigation replaced, have that page tell you with postMessage() before it unloads, or track clients yourself (see Messaging & the Clients API).
event.handled: knowing when the page has its response¶
event.handled starts pending and settles when fetch handling finishes. It resolves when the browser received a response from respondWith(), or when you did not call respondWith() and the request fell through to the network. It rejects with a NetworkError DOMException when handling produced a network error. In the spec that means the promise passed to respondWith() rejected or fulfilled with something that is not a Response, you called event.preventDefault() without calling respondWith(), or the worker could not run. Chromium goes further: its FetchRespondWithObserver also rejects handled for every response its type checks refuse (an opaque response for a cors request, a redirected response for a navigation, a used or locked body, and the rest of the table in Everything that becomes a network error). It is useful for deferring non-critical work until the page is no longer waiting:
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.pathname !== "/article") return;
event.respondWith(caches.match(event.request).then((r) => r ?? fetch(event.request)));
event.waitUntil(
event.handled
.then(() => recordView(url)) // runs only after the browser has the response
.catch(() => {
/* NetworkError: nothing was served, nothing to record */
}),
);
});
async function recordView(url) {
// Low-priority bookkeeping that should never compete with the response.
const cache = await caches.open("stats");
await cache.put(`/__stats${url.pathname}`, new Response(String(Date.now())));
}
handled shipped in Chrome 86, Firefox 84 and Safari 16. In older engines event.handled is undefined, so guard with event.handled?.then(...) if you still support them.
waitUntil() in fetch events¶
respondWith(r) adds r to the event's lifetime promises exactly as if you had called waitUntil(r). Everything else you want to finish after the response goes into event.waitUntil(): writing a clone to the cache, updating a stale entry, sending a log. Three rules matter:
- Asynchronous calls are allowed only while the event is still active. You may call
waitUntil()from inside a.then()as long as at least one earlier lifetime promise is still pending. Once all of them have settled, a late call throwsInvalidStateError; Chrome's message is "The event handler is already finished and no extend lifetime promises are outstanding." Register a long-lived promise early (for example the network request itself) when later callbacks will add more work. - The browser still enforces time limits. Chromium gives each event 5 minutes (
kRequestTimeoutin its source) before it may terminate the worker, and it stops idle workers after about 30 seconds without events. AwaitUntil()that hangs forever gets the worker killed, along with any other work in flight. Details are on the Lifecycle page. - Rejections do not affect the response. A rejected
waitUntil()promise in a fetch event does not turn the response into an error, but it does hide failures. Always.catch()and log cache writes, becauseQuotaExceededErroris common in production (see Storage Quotas & Persistence).
The Request object inside a fetch event¶
event.request is a normal Request object created by the browser, with two differences from the ones you construct: its headers guard is "immutable", so event.request.headers.set() throws a TypeError, and its signal is wired to the page's fetch controller. Every property tells you something useful for routing.
url, method and headers¶
request.url is the full, serialized URL. Parse it once with new URL(request.url) and route on origin, pathname and searchParams; string checks such as url.includes("/api/") match query strings and other origins by accident. request.method is normalized to upper case only for DELETE, GET, HEAD, OPTIONS, POST and PUT; any other method keeps the case the page used, so fetch(url, { method: "patch" }) arrives as patch, not PATCH. Compare methods case-insensitively if your pages are inconsistent. Header names in request.headers are case-insensitive; the ones worth reading are Accept, Range, Content-Type, Authorization and any custom headers your app sets, such as an API version.
Browsers do not expose everything they will eventually send. Cookies, the Origin header and other headers the browser adds late are not in event.request.headers; they are added again when you call fetch(event.request).
mode: navigate, same-origin, no-cors, cors¶
request.mode decides which response types you may return and how the browser treats cross-origin data:
| Mode | Typical initiators | You may respond with |
|---|---|---|
navigate | Top-level documents and iframes | basic or synthesized (default) responses, and opaqueredirect (navigations use redirect mode manual). Never opaque. |
same-origin | fetch(url, { mode: "same-origin" }), some worker scripts | Same-origin responses only; a cors response is a network error. |
no-cors | <img>, <script> and <link rel=stylesheet> without crossorigin, <video>, <audio>, CSS background-image | Anything, including opaque. |
cors | fetch() (the default), XMLHttpRequest, @font-face, module scripts, elements with crossorigin | basic, cors or synthesized. An opaque response is a network error. |
Two details trip people up. First, navigate cannot be created by script: new Request(url, { mode: "navigate" }) throws a TypeError, and new Request(event.request, init) with a non-empty init silently converts navigate to same-origin. Passing the original event.request object to fetch() is the only way to preserve navigation semantics. Second, the Fetch standard strongly discourages no-cors for new features: it exists for legacy HTML elements, and it is the source of every opaque response you will ever handle.
destination: what the response will be used for¶
request.destination is the most useful routing signal after the URL, because it tells you how the response will be consumed regardless of the file extension. The Fetch standard's RequestDestination values and their initiators:
destination | Initiated by | CSP directive |
|---|---|---|
"" (empty string) | fetch(), XMLHttpRequest, navigator.sendBeacon(), EventSource, <a ping>, <link rel=prefetch>, downloads | connect-src (varies) |
document | Top-level navigations | - |
iframe, frame | <iframe>, <frame> | child-src / frame-src |
image | <img>, srcset, <picture>, SVG <image>, CSS images, /favicon.ico | img-src |
style | <link rel=stylesheet>, CSS @import, import ... with { type: "css" } | style-src |
script | <script>, importScripts() from workers | script-src |
font | CSS @font-face | font-src |
audio, video, track | <audio>, <video>, <track> | media-src |
manifest | <link rel=manifest> | manifest-src |
worker, sharedworker | new Worker(), new SharedWorker() | worker-src |
audioworklet, paintworklet | audioWorklet.addModule(), CSS.paintWorklet.addModule() | script-src |
json | import ... with { type: "json" } | connect-src |
text | import ... with { type: "text" } (new: Firefox 153; Chrome 155 is in beta as of September 2026) | connect-src |
report | CSP and Network Error Logging reports | - |
xslt | <?xml-stylesheet?> | script-src |
embed, object | <embed>, <object> (never dispatched to a worker) | object-src |
The empty string is the important trap: every fetch() call from your application code has destination === "", so "API request" and "fetch() of a JSON file" look identical here. Route those by URL. Treat destinations you do not recognize as "fall through", because the list grows (Chromium also reports speculationrules, which is not in the standard enum yet).
credentials¶
request.credentials is omit, same-origin or include. Navigations and no-cors element loads use include, fetch() defaults to same-origin, and crossorigin="anonymous" on an element produces a cors request with same-origin credentials. When you re-issue a request with fetch(event.request) the browser keeps this setting, which is what you want. When you build a new request, you choose: a common mistake is converting a no-cors image request into new Request(url, { mode: "cors" }) and forgetting that the default credentials mode is now same-origin, so cookies that the image server relied on are no longer sent.
cache: the HTTP cache mode¶
request.cache reflects the page's instruction for the browser's HTTP cache, and fetch(event.request) honors it:
| Value | Meaning when you call fetch(event.request) |
|---|---|
default | Normal HTTP caching: fresh entries are used, stale ones revalidated. |
no-store | Bypass the HTTP cache completely and do not store the response. |
reload | Go to the network, then update the HTTP cache. |
no-cache | Use a cached entry only after revalidating with the server. |
force-cache | Use any cached entry, even stale; otherwise go to the network. |
only-if-cached | Only the HTTP cache; a miss is a network error. Allowed only with mode same-origin. |
The only-if-cached restriction is enforced by the Request constructor: if the browser ever hands you a request with cache: "only-if-cached" and a mode other than same-origin, fetch(event.request) throws "'only-if-cached' can be set only with 'same-origin' mode" (Chrome's wording). Defensive handlers skip such requests entirely. Remember too that the Cache API ignores request.cache: a page that fetched with cache: "no-store" will still get a Cache Storage hit if your strategy is cache-first. Respect it explicitly if it matters to your app:
function wantsFreshNetwork(request) {
return request.cache === "no-store" || request.cache === "reload";
}
The interaction between Cache Storage and the HTTP cache is covered in HTTP Caching & Service Workers.
redirect¶
request.redirect is follow (the default for fetch() and element loads), error or manual. Navigation requests always use manual, because the HTML navigation algorithm handles redirects itself. This has two consequences you must design for, both covered in Redirects:
fetch(event.request)for a navigation does not follow redirects. You get back a response of typeopaqueredirect(status0, no readable headers) that you can pass straight torespondWith(); the browser then performs the redirect and the next URL produces a newfetchevent.- You cannot answer a navigation with a response whose
redirectedflag istrue.
integrity, keepalive, signal and the rest¶
integrity- The Subresource Integrity metadata from the element (for example
sha384-...). The integrity check runs in the Fetch standard's main fetch on whatever response comes back, including one your worker supplied. You cannot bypass SRI from a service worker: if a cached copy does not match the hash, the page gets a network error. An opaque response can never pass an integrity check, which is why SRI-protected cross-origin scripts needcrossorigin. keepalivetruefor requests that must outlive the page, such asnavigator.sendBeacon()andfetch(url, { keepalive: true }). The Fetch standard caps the total in-flight keepalive body size at 64 KiB per fetch group. Respond quickly or not at all; the page may be gone by the time you finish.signal- An
AbortSignalthat the spec says is aborted when the page no longer wants the response (the page aborted itsfetch(), or navigated away). Forward it to any sub-request you make with a differentRequest:fetch(url, { signal: event.request.signal }). Implementations have lagged; Firefox's bug 1394102 remains open, so treat it as a best-effort optimization, not a correctness guarantee. referrer,referrerPolicy- The referrer the browser will send, as
"about:client"or a URL, and the policy that governs it. body,bodyUsed,duplex- See POST requests and request bodies.
request.bodyis aReadableStreamin Chromium and Safari; Firefox has not shipped the getter in a stable release, so read bodies witharrayBuffer(),text(),json()orformData()for portability.bytes(), which returns aUint8Array, is available in Chrome 132, Firefox 128 and Safari 18. isHistoryNavigation,isReloadNavigation- Chromium-only hints (Chrome 69 and, per MDN's data, Chrome 150 respectively) that tell you whether a navigation came from back/forward or from a reload. Useful for choosing a cache-first response on back navigation, but always feature-detect.
fetch(event.request) versus fetch(event.request.url)¶
These two calls look interchangeable and are not:
| Aspect | fetch(event.request) | fetch(event.request.url) |
|---|---|---|
| Mode | Preserved (navigate, no-cors, ...) | Always cors |
| Credentials | Preserved | same-origin |
| Method and body | Preserved (body consumed) | Always GET, no body |
Headers (Range, custom, Accept) | Preserved | Lost |
| Redirect mode | Preserved (manual for navigations) | follow |
| Cache mode, integrity, referrer | Preserved | Defaults |
Cross-origin no-cors image | Opaque response, works | CORS request, fails without Access-Control-Allow-Origin |
Use the URL form only when you deliberately want a different request, for example fetching a canonical cache key without query parameters.
Modifying a request: new Request(event.request, init)¶
Because event.request.headers is immutable, changing anything means constructing a new Request. The constructor applies rules that matter here:
- If
initis non-empty,navigatemode becomessame-origin, the reload and history-navigation flags are cleared, and the referrer and origin are reset to the worker's client. The spec explains why: a request "redirected" by a service worker should no longer appear to come from the original source. - In a
no-corsrequest with a non-emptyinit, privileged no-CORS request-headers are removed. Today that list is exactly one header:Range. Rebuilding a media request to add a header silently turns a range request into a full download. - A
no-corsrequest with a method other thanGET,HEADorPOSTthrows aTypeError. - Passing a stream body requires
duplex: "half"and a mode ofsame-originorcors. - Reusing
event.requestas input transfers its body: afternew Request(event.request, { headers }),event.request.bodyUsedistrue.
function withApiVersion(request) {
const headers = new Headers(request.headers); // mutable copy
headers.set("X-API-Version", "2026-09");
// mode/credentials/cache/redirect/body are copied from the original request.
return new Request(request, { headers });
}
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.origin !== self.location.origin || !url.pathname.startsWith("/api/")) return;
event.respondWith(fetch(withApiVersion(event.request)));
});
The rules of respondWith()¶
respondWith() is short to call and strict in what it accepts. The method steps in the Service Workers specification, plus the checks the Fetch standard applies to the result, give you the complete list.
Call it synchronously, during dispatch¶
The first step of respondWith() is: if the event's dispatch flag is unset, throw InvalidStateError. The dispatch flag is only set while listeners are running, so any call after your listener returns (after an await, in a .then(), in a setTimeout()) throws. Chrome's message is "The event handler is already finished."
// WRONG: respondWith() runs after the listener returned.
self.addEventListener("fetch", async (event) => {
const cached = await caches.match(event.request);
event.respondWith(cached ?? fetch(event.request)); // InvalidStateError
});
// RIGHT: call respondWith() immediately with a promise and await inside it.
self.addEventListener("fetch", (event) => {
event.respondWith(
(async () => {
const cached = await caches.match(event.request);
return cached ?? fetch(event.request);
})(),
);
});
The corollary is that the decision whether to respond must be synchronous. You can decide based on anything available without awaiting: the URL, method, mode, destination, headers, and state in global variables. You cannot first check whether something is cached and then decide to fall through. If you need that, commit to responding and fall back to fetch(event.request) inside the promise, or use the Static Routing API with a cache source, which the browser evaluates without your code.
Call it once, and know that it stops other listeners¶
The second step throws InvalidStateError if respondWith() was already called ("respondWith() was already called." in Chrome). The method also sets the event's stop propagation and stop immediate propagation flags, so once one listener responds, later fetch listeners never run. With several listeners, registration order is your routing order:
importScripts("/sw/analytics-proxy.js"); // registers a fetch listener first
importScripts("/sw/app-router.js"); // only sees requests the first one ignored
Frameworks and libraries that add their own fetch listener (Workbox's registerRoute(), push SDKs, analytics proxies) interact through this rule; list them in the order you want them to get first refusal.
Pass a Response or a promise for one¶
respondWith() accepts a Response or a promise. Upon fulfillment, if the value is not a Response object, the event's respond-with error flag is set and the page gets a network error ("an object that was not a Response was passed to respondWith()."). Returning undefined from a cache miss is the classic way to hit this:
event.respondWith(caches.match(event.request)); // undefined on a miss -> network error
event.respondWith(
caches.match(event.request).then((cached) => cached ?? fetch(event.request)),
);
Everything that becomes a network error¶
After your promise fulfills, the browser validates the response. The table combines the Fetch standard's checks in HTTP fetch with the exact console messages from Chromium's fetch_respond_with_observer.cc; every Chromium message is prefixed with The FetchEvent for "<url>" resulted in a network error response:.
| Condition | Chromium console message |
|---|---|
The promise passed to respondWith() rejected | "the promise was rejected." |
preventDefault() was called without respondWith() | "preventDefault() was called without calling respondWith()." |
The value was not a Response | "an object that was not a Response was passed to respondWith()." |
Response.error() (type error) | "the promise was resolved with an error response object." |
opaque response for a request whose mode is not no-cors | "an "opaque" response was used for a request whose type is not no-cors" |
opaque response for a navigation or worker script | "an "opaque" response was used for a client request." |
opaqueredirect response for a request whose redirect mode is not manual | "an "opaqueredirect" type response was used for a request whose redirect mode is not "manual"." |
cors response for a same-origin request | "a "cors" type response was used for a request whose mode is "same-origin"." |
redirected response for a request whose redirect mode is not follow | "a redirected response was used for a request whose redirect mode is not "follow"." |
Body already read (bodyUsed is true) | "a Response whose "bodyUsed" is "true" cannot be used to respond to a request." |
| Body locked by a reader | "a Response whose "body" is locked cannot be used to respond to a request." |
COEP require-corp page and a response that fails the CORP check | "Cross-Origin-Resource-Policy prevented from serving the response to the client." |
Per MDN's compatibility data, the same-origin/cors check is enforced by Chrome (66+) and Firefox (59+) but not by Safari, so a Safari-only test run can hide that bug.
Why these rules exist
Each check blocks a way to launder cross-origin data. Returning an opaque image response to a cors fetch() would let script read another origin's bytes; answering a navigation with an opaque response would render a cross-origin document at your URL. The restrictions are what keep a service worker, which sees every cross-origin request a page makes, from becoming a cross-origin read primitive. See Service Worker Security.
preventDefault() without respondWith()¶
FetchEvent is cancelable. If a listener calls event.preventDefault() and nobody calls respondWith(), the spec returns a network error to the page instead of falling back to the network. This is a way to block a request deliberately (for example, a tracking pixel on a privacy-sensitive page), and a nasty surprise if a shared helper calls preventDefault() out of habit.
Never let the promise reject¶
A rejection is a network error, and a network error for a navigation is the browser's offline dinosaur or error page, not yours. Structure every handler so the outermost promise always resolves with some Response: cached content, an offline page, a synthesized JSON error, or at worst an explicit Response.error() for subresources where no fallback makes sense. The router at the end of this page centralizes that in a catch handler.
Falling through to the network¶
If every listener returns without calling respondWith() (and nobody called preventDefault()), Handle Fetch returns nothing and the Fetch standard carries on with the original request exactly as if the worker did not exist: HTTP cache, network, redirects, CORS, all handled by the browser. This is the cheapest way to say "not mine", and it is almost always better than event.respondWith(fetch(event.request)) for requests you do not intend to change.
| Aspect | Falling through (no respondWith()) | respondWith(fetch(event.request)) |
|---|---|---|
| Worker start-up and dispatch | Paid | Paid |
| Response body path | Network to page directly | Network to worker to page |
Headers such as Range | Always preserved | Preserved in current Chromium and Safari; web.dev reported in 2020 that Firefox dropped Range |
| Redirects | Handled by the browser | Handled, but you must respect the redirect-mode rules |
| Failure mode | Normal network error handling | A rejected promise becomes a network error you might have prevented |
| DevTools | A normal request | Two entries: the page's request served by the worker, and the worker's own fetch() |
Guard clauses at the top of the listener are the idiomatic way to fall through:
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return; // writes go straight to the server
if (request.headers.has("range")) return; // media streaming: let the browser do it
if (request.cache === "only-if-cached" && request.mode !== "same-origin") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return; // third-party: not ours to cache
if (url.pathname.startsWith("/admin/")) return; // never serve admin pages from cache
event.respondWith(handle(event));
});
One subtle rule applies to request bodies. For bodies backed by bytes (form posts, JSON, FormData) the browser can resend them after you fall through even if your worker read its own copy. For a body created from a ReadableStream, the spec says that if the worker read that body and then did not respond, the request fails with a network error, because the bytes are gone. Only read streamed bodies in requests you will answer.
The cost of a fetch handler¶
A fetch listener changes the performance profile of every page it controls, even for requests it ignores. When the worker is not running, which in Chromium happens after about 30 seconds without events, the browser must start a thread, evaluate your script and dispatch the event before the request can proceed. Jake Archibald's web.dev article on navigation preload says boot-up is "usually around 50ms", "more like 250ms" on mobile, and can exceed 500 ms in extreme cases (slow devices, CPU under pressure). That delay sits in front of the navigation request, the most latency-sensitive request your site has.
The no-op fetch handler anti-pattern¶
For years, Chrome's installability check required a service worker with a fetch handler, so many sites shipped this:
It adds cost and does nothing. Chromium measured the damage: its March 2023 intent to ship said that 3-5% of the popular sites it investigated were affected. Two changes followed:
- Chromium skips no-op handlers. Chrome 112 started logging a console warning for them, and Chrome Platform Status records the skip itself as enabled by default from Chrome 115 on desktop and Android. When every
fetchlistener is a function with an empty body (Blink asks V8 whether each listener is a "nop function"), the browser does not put worker start-up and dispatch on the navigation's critical path, ignores navigation preload, and logs: "Fetch event handler is recognized as no-op. No-op fetch handler may bring overhead during navigation. Consider removing the handler if possible." The spec models this as the all fetch listeners are empty flag; the worker is still started in the background so the soft update check runs. - The fetch-handler requirement for installability is gone. Chrome dropped it for installation from the browser menu in version 108 on Android and 112 on desktop (the automatic prompt still required a fetch handler at that time), and current Chromium's install promotion pipeline has no service worker check at all (see Installability Criteria).
A handler with any real statement in it is not skippable, so "almost empty" handlers ((e) => { if (DEBUG) log(e); }) pay the full price. If your worker does not need to intercept requests (for example it only handles push), do not add a fetch listener at all.
Register listeners during the initial evaluation¶
The browser records which functional events a worker handles after the script's first evaluation. Chromium warns "Event handler of 'fetch' event must be added on the initial evaluation of worker script." if you add one later, for example inside a promise or after importScripts() inside a function, and a worker that had no fetch listener at evaluation time may never receive fetch events at all. Add every listener at the top level, synchronously.
Reducing the cost when you do need a handler¶
- Narrow the scope. A worker registered for
/app/never starts for/blog/navigations (see Registration & Scope). - Enable navigation preload so the navigation request runs in parallel with worker start-up (Navigation Preload).
- Declare bypass routes with the Static Routing API, supported in Chrome 123+ and Safari 27, so requests you would fall through on never wake the worker (Static Routing API).
- Keep
activatefast, because the first requests of a new worker wait for it. - Keep the top-level script small. Everything outside listeners runs on every cold start.
Chromium has also been rolling out an optional browser optimization called ServiceWorkerAutoPreload, which issues the navigation request in parallel with worker start-up automatically and hands the result to your fetch(event.request) or to the network fallback. According to its Chrome Platform Status entry and explainer:
- It applies only to main-resource
GETrequests, is applied at the browser's discretion (the explainer's eligibility criteria favor workers that often fall back to the network and workers that are not already running), and is never used when you have enabled navigation preload yourself. - The auto-preloaded response is consumed only by a
fetch()call for a request equal toevent.request. If you build a newRequestor clone and modify it, the browser discards the auto-preload and makes a fresh request; if you answer from the cache, it is simply discarded (and may be cancelled). - Redirects are still handed to your handler, so it sees every hop it would see without the optimization.
- Chrome Platform Status gives Chrome 140 as its shipping milestone, when the
ServiceWorkerAutoPreloadEnabledenterprise policy became available to administrators, and lists Chrome 154 (stable since September 22, 2026) as the milestone in which that policy is removed; its summary describes the optimization as part of Chrome 154. Because the browser decides when to apply it, you cannot assume it is active for any given user. - The explainer's documented opt-out is a static route that sends every URL to the fetch handler (
{ condition: { urlPattern: new URLPattern({}) }, source: "fetch-event" }); the spec only allows auto-preload when the matched router source is notfetch-event.
Do not design around it: enabling navigation preload explicitly gives you the same benefit in every engine that supports it, plus control over the server response.
Handling navigations and subresources differently¶
Navigations and subresource requests have different stakes and different rules, so most production workers route them separately.
Detecting a navigation¶
Use request.mode === "navigate". It is true for top-level documents and for iframes; check request.destination (document versus iframe or frame) if they need different handling. Older code sniffed request.headers.get("Accept").includes("text/html"), which also matches fetch() calls that ask for HTML and misses navigations with unusual Accept headers; there is no reason to use it today.
What is special about navigations¶
- Failure is visible. A network error on a navigation shows the browser's error page. Every navigation handler needs an offline fallback (see Offline UX & Fallbacks).
- Redirect mode is
manual. You can returnopaqueredirectresponses but not responses withredirected === true(see Redirects). - Opaque responses are never allowed, and the response determines the document's origin-level behavior: its CSP, COOP and COEP headers come from the headers you return. A cached HTML copy carries the headers it had when you cached it, so a CSP change on the server does not reach users served from an old cached page until you refresh that entry (see Content Security Policy).
- Only
GETnavigations get navigation preload. A form submission (method: "POST",mode: "navigate") always arrives without a preload response. - Navigations trigger update checks. Every controlled navigation schedules a soft update after dispatch.
Navigation strategies¶
| Site type | Typical navigation strategy | Why |
|---|---|---|
| Content site or server-rendered MPA | Network first with a timeout, cached copy, then offline page | Freshness matters; cached pages are a fallback. |
| SPA with an app shell | Cache first: always answer with the precached shell | The shell is versioned with the worker; data loads from APIs. See App Shell Model. |
| Hybrid (streamed shell + content) | Stream cached header and footer around a network body | Fast first paint and fresh content. See Streaming Responses. |
const PAGES = "pages";
const OFFLINE_URL = "/offline.html";
async function handleNavigation(event) {
const { request } = event;
try {
// With navigation preload enabled this was already requested in parallel
// with worker start-up; without it, preloadResponse resolves to undefined.
const response = (await event.preloadResponse) ?? (await fetch(request));
if (response.ok && response.type === "basic") {
const copy = response.clone();
event.waitUntil(
caches.open(PAGES).then((cache) => cache.put(request, copy)).catch(console.warn),
);
}
return response; // includes opaqueredirect and 4xx/5xx: let the page see them
} catch {
// Offline, DNS failure, or the preload request failed.
const cache = await caches.open(PAGES);
return (
(await cache.match(request, { ignoreSearch: true })) ??
(await caches.match(OFFLINE_URL)) ??
new Response("You are offline.", {
status: 503,
headers: { "Content-Type": "text/plain; charset=utf-8" },
})
);
}
}
self.addEventListener("fetch", (event) => {
if (event.request.mode === "navigate" && event.request.method === "GET") {
event.respondWith(handleNavigation(event));
}
});
ignoreSearch: true on the fallback lookup means /article?utm_source=mail can be served from the cached /article when offline. Use it only for fallbacks; for normal lookups the query string usually matters.
Subresources by destination¶
| Destination | Typical strategy | Notes |
|---|---|---|
script, style with hashed filenames | Cache first, never expires | The URL changes when the content does. |
script, style without hashes | Stale-while-revalidate | Avoid mixing versions: HTML and JS from different deploys break apps. |
image | Cache first with an entry limit | Beware opaque third-party images and their quota padding. |
font | Cache first | Font requests are cors requests, so responses are readable and cacheable. |
audio, video | Fall through, or serve cached files with range support | See Range requests and media. |
"" (fetch(), XHR) | Route by URL: network first for APIs, cache first for static JSON | Do not cache authenticated per-user responses without a plan to clear them on logout. |
manifest | Network first or stale-while-revalidate | A stale manifest delays app identity updates (see App Identity & Updates). |
The strategies themselves are covered in depth in Caching Strategies.
Routing patterns¶
A router answers one question synchronously: "for this request, which handler, if any?" The inputs are the URL, the method, the mode, the destination and the headers.
Matching on the URL¶
Always parse the URL and compare structured parts:
const url = new URL(event.request.url);
const isSameOrigin = url.origin === self.location.origin;
const isApi = isSameOrigin && url.pathname.startsWith("/api/");
const isHashedAsset = isSameOrigin && /^\/assets\/.+\.[0-9a-f]{8,}\.(js|css|woff2)$/.test(url.pathname);
const isThumbnail = url.hostname === "images.example-cdn.com" && url.searchParams.has("w");
Compare against self.location.origin rather than a hard-coded host so staging and production behave the same.
Matching with URLPattern¶
URLPattern gives you path-to-regexp style patterns with named groups, and it is available inside service workers. It shipped in Chrome 95, Firefox 142 and Safari 26, so every current engine has it, but keep a fallback if you support older Safari or Firefox releases.
// With baseURL, protocol/hostname/port are fixed to your origin, and search and
// hash become wildcards because only the pathname was specified.
const articlePattern = new URLPattern({
pathname: "/articles/:slug",
baseURL: self.location.origin,
});
const result = articlePattern.exec(event.request.url);
if (result) {
const { slug } = result.pathname.groups; // "/articles/pwa-routing" -> "pwa-routing"
}
// Cross-origin patterns list the hostname explicitly.
const cdnImages = new URLPattern({ hostname: "{*.}?example-cdn.com", pathname: "/img/*" });
A few URLPattern details matter for routers. test() and exec() accept a URL string or a URL object. Groups written as regular expressions (/:id(\\d+)) work in your own code, but patterns with regular-expression groups are rejected by the Static Routing API, which does not allow user-defined regular expressions; check pattern.hasRegExpGroups if you share patterns between the two. Pass { ignoreCase: true } as the options argument if your server treats paths case-insensitively.
Matching on destination, mode and method¶
const isNavigation = (r) => r.mode === "navigate";
const isRead = (r) => r.method === "GET" || r.method === "HEAD";
const isMedia = (r) => r.destination === "audio" || r.destination === "video";
const isRenderBlocking = (r) => r.destination === "style" || r.destination === "script";
const isOpaqueCandidate = (r, url) => r.mode === "no-cors" && url.origin !== self.location.origin;
HEAD requests deserve a mention: the Cache API only matches GET requests unless you pass ignoreMethod: true, so a HEAD request against a cache-first route silently misses. Most routers only handle GET and let everything else fall through.
First match wins¶
Order routes from most to least specific, and let the unmatched remainder fall through. A route table is easier to audit than nested conditionals, which is why Workbox's registerRoute() and the router at the end of this page are both lists evaluated in order.
Constructing responses¶
Everything you return is a Response, whether it came from the network, from Cache Storage or from your own code.
new Response(body, init)¶
| Rule | Behavior |
|---|---|
init.status outside 200-599 | RangeError. You cannot synthesize status 0 or a 1xx status. |
| Non-null body with status 101, 103, 204, 205 or 304 (null body status) | TypeError. Use new Response(null, { status: 204 }). |
init.statusText not a valid reason phrase | TypeError. |
Content-Type | Set automatically from the body when you do not provide one: text/plain;charset=UTF-8 for strings, the blob's type for a Blob, multipart/form-data; boundary=... for FormData, application/x-www-form-urlencoded;charset=UTF-8 for URLSearchParams. Streams and buffers get none. |
Set-Cookie / Set-Cookie2 | Forbidden response-header names: silently dropped. A service worker cannot set cookies through a synthesized response. |
response.type | default, which the page treats like a same-origin response. |
response.url | Empty string; the page sees the request URL as the response URL. |
// HTML, with headers the browser will honor as if the server sent them.
const html = new Response("<!doctype html><title>Maintenance</title><p>Back soon.</p>", {
status: 503,
statusText: "Service Unavailable",
headers: {
"Content-Type": "text/html; charset=utf-8",
"Retry-After": "120",
"Cache-Control": "no-store",
},
});
// An empty success.
const noContent = new Response(null, { status: 204 });
// A small SVG placeholder for failed images.
const placeholder = new Response(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3"><rect width="4" height="3" fill="#ddd"/></svg>',
{ headers: { "Content-Type": "image/svg+xml" } },
);
Response.json()¶
Response.json(data, init) serializes data with JSON.stringify semantics and sets Content-Type: application/json unless you provide one. It shipped in Chrome 105, Firefox 115 and Safari 17. Two edge cases: values that serialize to undefined (for example Response.json(undefined) or a bare function) throw TypeError, and BigInt values throw TypeError just as JSON.stringify does.
function json(data, init = {}) {
if (typeof Response.json === "function") return Response.json(data, init);
const headers = new Headers(init.headers);
if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
return new Response(JSON.stringify(data), { ...init, headers });
}
// json({ error: "offline", retryAfter: 30 }, { status: 503 })
Response.redirect()¶
Response.redirect(url, status = 302) creates a response with a Location header and an immutable Headers object. The URL is parsed against the service worker's script URL (its API base URL), so relative paths such as "/login" resolve against your origin; an unparsable URL throws TypeError. The status must be 301, 302, 303, 307 or 308, otherwise RangeError.
What the browser does with it depends on the request:
- Navigations follow it as a normal HTTP redirect, and the new URL produces a fresh
fetchevent. Use303after handling a formPOSTso the follow-up request is aGET. - Subresource requests with redirect mode
followalso follow it, and because the redirect came from the worker rather than the network, the redirected request is dispatched to your worker again. A route that redirects/ato/aloops until the browser's 20-redirect limit turns it into a network error. - Requests with redirect mode
errorfail, and non-navigation requests with redirect modemanual(for examplefetch(url, { redirect: "manual" })) receive anopaqueredirectresponse.
Response.error()¶
Response.error() returns a response of type error with status 0. Passing it to respondWith() gives the page a network error, the same thing a rejected promise would, but deliberately and without a console stack trace pointing at your code. It is the right "no fallback exists" answer for a failed subresource.
Cloning and consuming bodies¶
A body can be read once. response.clone() tees the body into two independent streams, and it must happen before either copy is read or passed to respondWith(), otherwise it throws TypeError. Cloning is not free: when one branch is read faster than the other, the browser buffers the difference in memory, so cloning a 200 MB video to cache it while the page streams it holds data in memory until the slower branch catches up.
async function networkAndCache(event) {
const response = await fetch(event.request);
const copy = response.clone(); // synchronous, before anything reads the body
event.waitUntil(
caches.open("runtime").then((cache) => cache.put(event.request, copy)).catch(console.warn),
);
return response; // the page reads this branch, the cache reads the other
}
Copying a response to change status or headers¶
Response headers from the network are immutable, so changing them means building a new Response around the same body:
function withHeaders(response, extra) {
if (response.type === "opaque" || response.type === "opaqueredirect") {
return response; // status 0 cannot be copied: new Response() would throw RangeError
}
const headers = new Headers(response.headers);
for (const [name, value] of Object.entries(extra)) headers.set(name, value);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
// During development, label every response with the route that produced it.
// return withHeaders(response, { "X-SW-Route": "api-network-first" });
The copy loses response.url and response.redirected, which is exactly why the same technique fixes redirected responses for navigations.
Opaque responses¶
A no-cors request to another origin that does not go through CORS produces an opaque response. It is the one response type your worker can hold but not inspect.
| Property | Opaque response value |
|---|---|
type | "opaque" |
status | 0 (the real status is hidden) |
ok | false |
statusText | "" |
headers | Empty |
body | null; text(), blob() and friends resolve to empty values |
url | "" |
Where opaque responses are allowed¶
Only as the answer to a request whose mode is no-cors and which is not a navigation or worker script. The page's element (an <img>, a classic <script>) can still use the bytes, because the browser, not script, consumes them. Anything else is a network error, and when the page is cross-origin isolated (COEP require-corp), Chromium also runs the Cross-Origin-Resource-Policy check before handing an opaque response to the page.
Why caching them is risky¶
- You cannot tell success from failure. A 404 page, a 500 error or a captive-portal login page all look identical: status
0. Cache one with a cache-first strategy and you serve the error forever. - Quota padding. To avoid leaking cross-origin response sizes through
navigator.storage.estimate(), browsers pad the size they account for opaque entries. Chromium adds a pseudo-random padding between 0 and about 14 MiB to each opaque response, about 7 MiB on average, however small the response is (older Workbox documentation describes this as a fixed "7 megabytes" minimum, which no longer matches the implementation). Two hundred cached third-party thumbnails can therefore account for over a gigabyte of quota in Chrome. The padding is implementation-defined, so measure in each engine you target (see Storage Quotas & Persistence). - Security headers are invisible, so you cannot honor the origin's
Cache-Control: no-storeorVary.
How to avoid opaque responses¶
The durable fix is to make the request a CORS request. Add crossorigin="anonymous" to <img>, <script>, <link> and <video> elements that load cacheable third-party resources, and have the other origin send Access-Control-Allow-Origin. The response becomes type cors: readable status, readable headers, exact quota accounting. If you control neither the markup nor the server, the Workbox guidance is to use network-first or stale-while-revalidate for opaque responses, so a bad entry is replaced on the next successful request, and to opt in to caching them explicitly (Workbox's CacheableResponsePlugin with statuses: [0, 200]) only with an expiration limit.
const MAX_OPAQUE_ENTRIES = 20; // ~140 MB of padded quota in Chrome
async function thirdPartyImage(event) {
const cache = await caches.open("third-party-images");
const cached = await cache.match(event.request);
const network = fetch(event.request).then((response) => {
// Status 0 is all we get; cache it, but keep the cache small. The write runs
// in the background so a slow or failing cache.put() (QuotaExceededError is
// likely with padded entries) never delays or breaks the image itself.
if (response.type === "opaque" || response.ok) {
const copy = response.clone(); // before the page starts reading the body
event.waitUntil(
(async () => {
await cache.put(event.request, copy);
const keys = await cache.keys(); // insertion order: oldest first
for (const key of keys.slice(0, Math.max(0, keys.length - MAX_OPAQUE_ENTRIES))) {
await cache.delete(key);
}
})().catch((error) => console.warn("[sw] opaque image cache write failed", error)),
);
}
return response;
});
event.waitUntil(network.catch(() => {})); // keep the revalidation alive on cache hits
return cached ?? network; // stale-while-revalidate: a bad entry lives one visit at most
}
CORS inside the service worker¶
Your worker runs in your origin, so every fetch() it makes is a request from your origin: same-origin requests need nothing special, and cross-origin requests follow the normal CORS protocol, including preflights for non-safelisted methods and headers. The response type your worker receives, basic, cors or opaque, is then checked against the page's original request mode as described in Everything that becomes a network error.
You can upgrade a no-cors request to a CORS request when you know the other origin supports CORS, which turns an opaque response into a readable one:
async function corsFirst(event) {
const { request } = event;
if (request.mode !== "no-cors") return fetch(request);
try {
// credentials: "omit" matches crossorigin="anonymous" semantics.
const corsRequest = new Request(request.url, { mode: "cors", credentials: "omit" });
return await fetch(corsRequest); // type "cors": readable and exactly accounted
} catch {
// No Access-Control-Allow-Origin (or offline): fall back to the original
// request, which yields an opaque response the element can still use.
return fetch(request);
}
}
Returning a cors response to a no-cors request is allowed. The cost of this pattern is a second request whenever the server does not support CORS, so apply it only to origins you know answer with CORS headers, and remember that credentials: "omit" drops cookies the original request would have sent.
Range requests and media¶
<video> and <audio> elements request media in pieces with a Range header (Range: bytes=0-, then specific windows as the user seeks), and expect 206 Partial Content answers with a Content-Range header. The Cache API was not designed for this:
cache.put()rejects a206response with aTypeError, andcache.add()andcache.addAll()reject one too.- Cache matching ignores the
Rangeheader. A lookup for a range request returns the full200entry. - Rebuilding a
no-corsmedia request with a non-emptyinitstripsRange, because it is the only privileged no-CORS request-header in the Fetch standard. - An opaque
206can only answer a request that has aRangeheader. The Fetch standard's range-requested flag turns it into a network error otherwise, which blocks attacks that splice partial cross-origin responses into APIs that never asked for a range. - Engines differ in strictness. Answering a media range request with a full
200works in some engines and not others; Safari is known to refuse to play video from a service worker unless it gets proper206responses, as Phil Nash documented in "Service workers: beware Safari's range request". Always answer range requests with real206responses.
So the practical rules are:
- Uncached media: fall through. Do not call
respondWith()for range requests you are not serving from cache. The browser talks to the server directly with the originalRangeheader. (web.dev reported that Chrome and Edge 87+ and recent Safari forwardRangecorrectly onfetch(event.request), while Firefox dropped it as of October 2020; falling through sidesteps the question.) - Cached media: build the
206yourself from the cached full response. - Cache media with a full, non-range request (
cache.add("/media/intro.mp4")sends noRangeheader) and make sure it is same-origin or CORS: an opaque cached entry has no readable body to slice.
/** Parse one "bytes=" range. Returns {start, end}, "unsatisfiable", or null (ignore Range). */
function parseRange(header, size) {
if (!header) return null; // no Range header: serve the full body
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match) return null; // multiple ranges or another unit: serve the full body
const [, first, last] = match;
if (first === "" && last === "") return null;
if (first === "") {
// Suffix range: the last N bytes.
const suffix = Number(last);
if (suffix === 0) return "unsatisfiable";
return { start: Math.max(size - suffix, 0), end: size - 1 };
}
const start = Number(first);
if (last !== "" && Number(last) < start) return null; // invalid range-spec: ignore it
if (start >= size) return "unsatisfiable";
const end = last === "" ? size - 1 : Math.min(Number(last), size - 1);
return { start, end };
}
async function rangeFromCache(event, cacheName) {
const { request } = event;
const cache = await caches.open(cacheName);
const cached = await cache.match(request); // Range is ignored when matching
if (!cached) return fetch(request);
const blob = await cached.blob();
const range = parseRange(request.headers.get("range"), blob.size);
const headers = new Headers(cached.headers);
headers.set("Accept-Ranges", "bytes");
if (range === null) {
headers.set("Content-Length", String(blob.size));
return new Response(blob, { status: 200, headers });
}
if (range === "unsatisfiable") {
return new Response(null, {
status: 416,
statusText: "Range Not Satisfiable",
headers: { "Content-Range": `bytes */${blob.size}` },
});
}
const slice = blob.slice(range.start, range.end + 1); // end is inclusive in HTTP
headers.set("Content-Range", `bytes ${range.start}-${range.end}/${blob.size}`);
headers.set("Content-Length", String(slice.size));
return new Response(slice, { status: 206, statusText: "Partial Content", headers });
}
The parsing follows RFC 9110: a suffix range (bytes=-500) means the last 500 bytes, an open range (bytes=1000-) runs to the end, an end position beyond the file is clamped, a start position at or beyond the end is 416 with Content-Range: bytes */<size>, and a syntactically invalid range is ignored in favor of a full 200. Multi-range requests (bytes=0-99,200-299) would require a multipart/byteranges body; media elements do not send them, so serving the full resource is the pragmatic answer. If you use Workbox, the workbox-range-requests module implements the same logic as a plugin (see Advanced Workbox).
POST requests and request bodies¶
Writes need different handling from reads, and the constraints come from both the Cache API and the one-shot nature of bodies.
What you cannot do with non-GET requests¶
cache.put()andcache.add()reject any request whose method is notGETwith aTypeError.cache.match()ignores non-GETrequests (returnsundefined) unless you passignoreMethod: true, in which case the method is simply not compared.- Navigation preload only runs for
GETnavigations.
So a service worker cannot "cache a POST". It can store the request's data somewhere else (IndexedDB) and replay it, or answer with a synthesized response.
Bodies are read once¶
event.request has a body stream that can be consumed exactly once. fetch(event.request) consumes it; so do await event.request.json() and new Request(event.request, init). If you need the body and need to forward the request, clone first:
self.addEventListener("fetch", (event) => {
if (event.request.method !== "POST") return;
event.respondWith(
(async () => {
const forLogging = event.request.clone(); // clone BEFORE fetch() consumes the body
const response = await fetch(event.request);
event.waitUntil(
forLogging
.text()
.then((body) => audit(event.request.url, body))
.catch((error) => console.warn("[sw] audit failed", error)), // never affects the response
);
return response;
})(),
);
});
async function audit(url, body) {
// Keep a small, size-bounded diagnostic trail. Never log secrets: redact or
// skip endpoints that carry credentials or personal data.
const cache = await caches.open("post-audit");
await cache.put(
`/__audit/${Date.now()}`,
new Response(JSON.stringify({ url, bytes: body.length, at: new Date().toISOString() }), {
headers: { "Content-Type": "application/json" },
}),
);
}
Forgetting the clone produces TypeError: Failed to execute 'fetch' on 'ServiceWorkerGlobalScope': Cannot construct a Request with a Request object that has already been used. in Chrome. Portability note: read bodies with arrayBuffer(), blob(), text(), json() or formData(). The request.body stream getter exists in Chromium (105+) and Safari but not in stable Firefox, and streaming uploads (fetch(url, { body: stream, duplex: "half" })) are Chromium-only.
An offline outbox for API writes¶
The classic pattern: try the network; on a network failure, store the request in IndexedDB, answer 202 Accepted, and replay later.
const OUTBOX_DB = "outbox";
const OUTBOX_STORE = "requests";
function openOutbox() {
return new Promise((resolve, reject) => {
const open = indexedDB.open(OUTBOX_DB, 1);
open.onupgradeneeded = () => {
open.result.createObjectStore(OUTBOX_STORE, { keyPath: "id", autoIncrement: true });
};
open.onsuccess = () => resolve(open.result);
open.onerror = () => reject(open.error);
});
}
function txDone(tx) {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
async function enqueue(request) {
const entry = {
url: request.url,
method: request.method,
headers: [...request.headers], // plain arrays are structured-cloneable
body: await request.arrayBuffer(),
queuedAt: Date.now(),
};
const db = await openOutbox();
const tx = db.transaction(OUTBOX_STORE, "readwrite");
tx.objectStore(OUTBOX_STORE).add(entry);
await txDone(tx);
db.close();
}
async function handleApiWrite(event) {
const backup = event.request.clone(); // fetch() below consumes the original body
try {
return await fetch(event.request); // 4xx/5xx resolve normally and reach the page
} catch {
// TypeError: offline, DNS or TLS failure. Queue and acknowledge.
await enqueue(backup);
if ("sync" in self.registration) {
await self.registration.sync.register("outbox").catch(() => {});
}
return new Response(JSON.stringify({ queued: true }), {
status: 202,
headers: { "Content-Type": "application/json" },
});
}
}
async function replayOutbox() {
const db = await openOutbox();
const entries = await new Promise((resolve, reject) => {
const req = db.transaction(OUTBOX_STORE).objectStore(OUTBOX_STORE).getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
try {
for (const entry of entries) {
// Throws while still offline: the sync event fails and will be retried.
const response = await fetch(entry.url, {
method: entry.method,
headers: entry.headers,
body: entry.body,
});
if (response.status >= 500) throw new Error(`Server error ${response.status}`);
// 2xx done; 4xx will never succeed, so drop it (and tell the user in real code).
const tx = db.transaction(OUTBOX_STORE, "readwrite");
tx.objectStore(OUTBOX_STORE).delete(entry.id);
await txDone(tx);
}
} finally {
db.close();
}
}
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method === "POST" && url.origin === self.location.origin && url.pathname.startsWith("/api/")) {
event.respondWith(handleApiWrite(event));
}
});
self.addEventListener("sync", (event) => {
if (event.tag === "outbox") event.waitUntil(replayOutbox());
});
Background Sync is Chromium-only, so production code also replays when a page reports it is back online (see Background Sync and Offline-First Data & Sync). Only queue idempotent operations, or include an idempotency key header so a replay after an ambiguous failure does not create duplicates.
Form navigations and share targets¶
A <form method="post"> submission is a navigation with method: "POST". When you handle it in the worker, answer with a 303 redirect so the browser ends up on a GET URL and a reload does not resubmit:
async function handleFormPost(event) {
try {
const response = await fetch(event.request.clone()); // server handles it normally
return response; // typically already a 303 from the server
} catch {
await enqueue(event.request);
return Response.redirect("/submitted?queued=1", 303);
}
}
The same shape handles a manifest share_target that uses POST: the worker reads await event.request.formData(), stores the shared files, and redirects to a page that displays them (see Web Share Target).
Streaming responses¶
The body you return does not have to exist yet. If you respond with a Response whose body is a ReadableStream, the browser forwards bytes to the page as you enqueue them, and HTML starts parsing and rendering before the stream finishes. The spec pipes your stream into a new one owned by the browser and notes that the page will read "the same data that was written, but it may be chunked differently".
async function streamedArticle(event) {
const url = new URL(event.request.url);
const header = caches.match("/partials/header.html");
const body = fetch(`/partials${url.pathname}.html`).catch(() =>
caches.match("/partials/offline-body.html"),
);
const footer = caches.match("/partials/footer.html");
const { readable, writable } = new TransformStream();
const done = (async () => {
try {
for (const part of [header, body, footer]) {
const response = await part;
if (!response?.body) continue;
// preventClose keeps the writable open for the next part.
await response.body.pipeTo(writable, { preventClose: true });
}
await writable.close();
} catch (error) {
await writable.abort(error).catch(() => {});
}
})();
// Keep the worker alive until the last byte has been written.
event.waitUntil(done);
return new Response(readable, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
Three constraints to design around: the status and headers are committed when you return the Response, so a failure halfway through can only truncate or error the body; errors after that point surface in the page as a failed or incomplete load, not as your offline page; and the worker must stay alive until the stream is done, hence event.waitUntil(done). The full treatment, including composing streams with navigation preload and TextEncoderStream for generated markup, is on Streaming Responses.
Redirects¶
Redirects are where service worker code most often breaks navigations, because the rules differ by request type and a Response remembers whether it was redirected.
response.redirected and the URL list¶
Every response has a URL list: the request URL plus one entry per redirect followed. response.redirected is true when that list has more than one entry, and response.url is its last entry. fetch() with the default redirect mode follow produces such responses; so do cache.add() and cache.addAll(), which fetch with follow, and Cache Storage stores the URL list along with the response.
Why redirected responses break navigations¶
The Fetch standard turns a service worker response into a network error when the request's redirect mode is not follow and the response's URL list has more than one item. Navigations always use manual. The classic failure:
- You precache
/about, and the server redirects it to/about/. cache.addAll()follows the redirect and stores a response withredirected === true.- A user navigates to
/about; your cache-first handler returns that entry. - Chrome logs
The FetchEvent for "https://example.com/about" resulted in a network error response: a redirected response was used for a request whose redirect mode is not "follow".and the user sees an error page.
The rule exists for security: letting a worker answer a navigation with a response that was redirected elsewhere would let it present content from one URL under another. The fix is to store a clean copy whose URL list is empty:
function cleanResponse(response) {
if (!response.redirected) return response;
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open("pages-v3");
for (const url of ["/", "/about", "/offline.html"]) {
const request = new Request(url, { cache: "reload" });
const response = await fetch(request);
if (!response.ok) throw new Error(`${url} -> ${response.status}`);
await cache.put(request, cleanResponse(response));
}
})(),
);
});
Workbox exposes the same idea as copyResponse() in workbox-core. Better still, precache the final URLs (/about/) so there is nothing to clean.
fetch() for a navigation returns opaqueredirect¶
Because a navigation's redirect mode is manual, fetch(event.request) does not follow a server redirect. You get a response of type opaqueredirect: status 0, no headers, no body. Pass it to respondWith() unchanged and the browser performs the redirect, and the target URL produces a new fetch event. Chromium's navigation preload does the same: when the preload request is redirected, event.preloadResponse resolves to an opaqueredirect response. Two consequences:
- Do not treat
!response.okas failure for navigations. Anopaqueredirecthasok === false; if you then serve a cached page instead, you silently break every redirect on your site, including logins. - Do not cache it. It has no body and its target is invisible to you; a cached one replays a stale redirect.
Redirects you synthesize¶
Response.redirect() responses are redirect responses, not redirected responses: their URL list is empty, so they satisfy navigations. As described in Response.redirect(), the browser follows them, and for subresources the follow-up request is dispatched to your worker again (network redirects are not re-dispatched). Synthesized redirects are useful for:
- Sending offline users of a deep link to a cached equivalent:
Response.redirect("/offline/article", 302). - Normalizing URLs (
/index.htmlto/) without a server round trip. - Finishing a form
POSTwith303 See Other.
The final URL of a subresource¶
Per the spec, when you answer a subresource request with a Response whose url differs from the request URL (for example, you fetched /v2/app.css to answer a request for /app.css), that URL becomes the resource's final URL: CSS @import and url() references and a worker's importScripts() resolve against it. MDN's compatibility data shows Firefox implementing this (since 59) and Chrome and Safari still using the request URL. Navigations never adopt the response URL, which is what lets you serve an offline page at the URL the user asked for. Avoid depending on either behavior: serve assets under their real URLs.
Error handling¶
A fetch handler meets four kinds of failure, and each needs a different response.
| Failure | How it surfaces | Typical answer |
|---|---|---|
| Network unavailable, DNS or TLS error, CORS failure | fetch() rejects with TypeError | Cached copy, then an offline fallback |
| HTTP error (404, 500, 503) | fetch() resolves; response.ok === false | Usually pass it through; do not cache it |
| Timeout or abort | fetch() rejects with an AbortError (or your signal's reason) | Cached copy if the network is too slow |
| Storage problems | cache.put() rejects (QuotaExceededError, TypeError for 206, non-GET, Vary: * or a used body) | Log and continue; never fail the response because a cache write failed |
| Bugs in your code | Any exception inside the promise | A catch-all fallback so the page still gets a response |
Timeouts that do not break navigations¶
A network-first strategy needs a timeout, or "lie-fi" (a connection that is technically up but not delivering) keeps users staring at a blank page. Two details matter. First, racing a timer against the network is usually better than aborting the request: if no cached copy exists, you still want the network answer when it arrives. Second, if you do abort, remember that constructing new Request(event.request, { signal }) turns a navigation into a same-origin request; pass the signal to fetch() only for non-navigation requests:
async function fetchWithTimeout(request, ms) {
// A non-empty init turns "navigate" into "same-origin" and strips Range from
// no-cors requests, so leave those requests untouched.
if (request.mode === "navigate" || request.headers.has("range")) return fetch(request);
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(new DOMException(`No response within ${ms} ms`, "TimeoutError")),
ms,
);
// If the page gives up (where engines wire up request.signal), give up too.
const onPageAbort = () => controller.abort(request.signal.reason);
request.signal?.addEventListener("abort", onPageAbort, { once: true });
try {
return await fetch(request, { signal: controller.signal });
} finally {
clearTimeout(timer);
request.signal?.removeEventListener("abort", onPageAbort);
}
}
AbortSignal.timeout() and AbortSignal.any() express the same thing more compactly, but they arrived in different engines at different times (MDN lists AbortSignal.any() for Chrome 116, Firefox 124 and Safari 17.4), so the manual version is the portable one.
Fallbacks by destination¶
The fallback has to be something the requesting context can use. An HTML offline page is right for a navigation and useless for an image or a fetch() expecting JSON:
| Destination | Fallback | Status |
|---|---|---|
document, iframe | Cached copy, then a precached offline page | Cached page's status, or 200/503 for the offline page |
image | A precached placeholder SVG | 200 |
font | Nothing; the browser uses the fallback font | Network error (Response.error()) |
script, style | Nothing that could run safely | Network error |
"" with Accept: application/json | {"error":"offline"} | 503 with Retry-After |
audio, video | Nothing; the element fires error | Network error |
Serving the offline page with status 200 makes it look like a successful load to your analytics and to any code that checks the status; 503 is more honest and lets real-user monitoring separate offline views. Choose deliberately, and see Offline UX & Fallbacks for designing the page itself.
A complete small router¶
The file below puts the page together: synchronous route matching with URLPattern (and a fallback for engines without it), network-first navigations that use navigation preload, cache-first hashed assets and images with an entry limit, network-first API reads with a timeout, range support for cached media, redirect cleaning during precaching, and destination-aware offline fallbacks. It has no dependencies and is roughly 400 lines including comments.
// sw.js - a small, dependency-free fetch router for a PWA.
// Routes are matched synchronously (respondWith() must be called during
// dispatch); handlers are async and always resolve with a Response.
const VERSION = "2026-09-25";
const CACHES = {
shell: `shell-${VERSION}`,
assets: `assets-${VERSION}`,
pages: "pages",
api: "api",
images: "images",
media: "media",
fonts: "fonts",
};
const OFFLINE_PAGE = "/offline.html";
const OFFLINE_IMAGE = "/img/offline.svg";
const PRECACHE_URLS = ["/", OFFLINE_PAGE, OFFLINE_IMAGE, "/css/app.css", "/js/app.js"];
const noop = () => {};
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
class Router {
#routes = [];
#catchHandler = null;
/**
* @param {string} name Used in logs.
* @param {(ctx: RouteContext) => boolean} match MUST be synchronous.
* @param {(ctx: RouteContext) => Promise<Response>} handler
*/
add(name, match, handler) {
this.#routes.push({ name, match, handler });
return this;
}
/** Runs when a handler throws or rejects (offline, quota, bugs...). */
setCatchHandler(handler) {
this.#catchHandler = handler;
return this;
}
/**
* Returns a Promise<Response> for the first matching route, or null when
* no route matches (the caller then does NOT call respondWith()).
*/
handle(event) {
const { request } = event;
const url = new URL(request.url);
for (const route of this.#routes) {
const ctx = { event, request, url, params: {} };
if (route.match(ctx)) return this.#run(route, ctx);
}
return null; // (1)!
}
async #run(route, ctx) {
try {
const response = await route.handler(ctx);
if (!(response instanceof Response)) {
throw new TypeError(`Route "${route.name}" did not return a Response`);
}
return response;
} catch (error) {
console.warn(`[sw] route "${route.name}" failed for ${ctx.request.url}`, error);
if (this.#catchHandler) {
try {
return await this.#catchHandler(ctx, error);
} catch (fallbackError) {
console.error("[sw] catch handler failed", fallbackError);
}
}
return Response.error(); // (2)!
}
}
}
// ---------------------------------------------------------------------------
// Matchers (all synchronous)
// ---------------------------------------------------------------------------
/** Compile "/posts/:id/*" into a matcher; uses URLPattern when available. */
function path(pattern, { sameOrigin = true } = {}) {
if (typeof URLPattern === "function") {
const compiled = sameOrigin
? new URLPattern({ pathname: pattern, baseURL: self.location.origin })
: new URLPattern({ pathname: pattern });
return (ctx) => {
const result = compiled.exec(ctx.url.href);
if (!result) return false;
ctx.params = result.pathname.groups;
return true;
};
}
// Fallback for engines without URLPattern: ":name" segments and "*".
const source = pattern
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
.replace(/:([A-Za-z_]\w*)/g, "(?<$1>[^/]+)")
.replace(/\*/g, ".*");
const regex = new RegExp(`^${source}$`);
return (ctx) => {
if (sameOrigin && ctx.url.origin !== self.location.origin) return false;
const result = regex.exec(ctx.url.pathname);
if (!result) return false;
ctx.params = { ...result.groups };
return true;
};
}
const all = (...matchers) => (ctx) => matchers.every((m) => m(ctx));
const method = (name) => ({ request }) => request.method === name;
const destination = (...names) => ({ request }) => names.includes(request.destination);
const isNavigation = ({ request }) => request.mode === "navigate" && request.method === "GET";
const hasRange = ({ request }) => request.headers.has("range");
const origin = (value) => ({ url }) => url.origin === value;
const isPrecached = ({ url }) =>
url.origin === self.location.origin && PRECACHE_URLS.includes(url.pathname);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isCacheable(response, { allowOpaque = false } = {}) {
if (!response) return false;
if (response.type === "opaque") return allowOpaque;
if (!response.ok || response.status === 206) return false;
const cacheControl = response.headers.get("Cache-Control") || "";
return !/\bno-store\b/i.test(cacheControl);
}
function reportCacheError(error) {
// QuotaExceededError is the one you will actually see in production.
console.warn("[sw] cache write failed", error);
}
function json(data, init = {}) {
if (typeof Response.json === "function") return Response.json(data, init);
const headers = new Headers(init.headers);
if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
return new Response(JSON.stringify(data), { ...init, headers });
}
/** Copy a response so its URL list is empty (response.redirected === false). */
function cleanResponse(response) {
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
async function trimCache(cache, maxEntries) {
const keys = await cache.keys(); // oldest first: the spec keeps insertion order
for (const request of keys.slice(0, Math.max(0, keys.length - maxEntries))) {
await cache.delete(request);
}
}
// ---------------------------------------------------------------------------
// Strategies
// ---------------------------------------------------------------------------
const TIMED_OUT = Symbol("timed out");
/**
* Network first with a timeout. If the network is slower than timeoutMs and a
* cached copy exists, serve the cache; otherwise keep waiting for the network.
*/
async function networkFirst(ctx, { cacheName, timeoutMs = 4000, fetcher }) {
const { event, request } = ctx;
const cache = await caches.open(cacheName);
const network = (fetcher ? fetcher(ctx) : fetch(request)).then((response) => {
if (isCacheable(response)) {
event.waitUntil(cache.put(request, response.clone()).catch(reportCacheError));
}
return response;
});
// Keep the worker alive until the network settles, even if the cache wins.
event.waitUntil(network.then(noop, noop)); // (3)!
let timer;
const timeout = new Promise((resolve) => {
timer = setTimeout(resolve, timeoutMs, TIMED_OUT);
});
try {
const winner = await Promise.race([network, timeout]);
if (winner !== TIMED_OUT) return winner;
return (await cache.match(request)) ?? (await network);
} catch (error) {
const cached = await cache.match(request);
if (cached) return cached;
throw error; // let the router's catch handler build the fallback
} finally {
clearTimeout(timer);
}
}
async function cacheFirst(ctx, { cacheName, maxEntries, allowOpaque = false }) {
const { event, request } = ctx;
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
if (isCacheable(response, { allowOpaque })) {
const copy = response.clone(); // clone BEFORE the body reaches the page
event.waitUntil(
cache
.put(request, copy)
.then(() => (maxEntries ? trimCache(cache, maxEntries) : undefined))
.catch(reportCacheError),
);
}
return response;
}
async function staleWhileRevalidate(ctx, { cacheName }) {
const { event, request } = ctx;
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const network = fetch(request).then((response) => {
if (isCacheable(response)) {
event.waitUntil(cache.put(request, response.clone()).catch(reportCacheError));
}
return response;
});
event.waitUntil(network.then(noop, noop));
return cached ?? network;
}
// ---------------------------------------------------------------------------
// Range requests for cached media
// ---------------------------------------------------------------------------
/** Parse a single "bytes=" range. Returns {start,end}, "unsatisfiable" or null (ignore). */
function parseRange(header, size) {
if (!header) return null; // no Range header: serve the full body
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match) return null; // multiple ranges or another unit: serve the full body
const [, first, last] = match;
if (first === "" && last === "") return null;
if (first === "") {
const suffix = Number(last);
if (suffix === 0) return "unsatisfiable";
return { start: Math.max(size - suffix, 0), end: size - 1 };
}
const start = Number(first);
if (last !== "" && Number(last) < start) return null; // invalid spec: ignore Range
if (start >= size) return "unsatisfiable";
const end = last === "" ? size - 1 : Math.min(Number(last), size - 1);
return { start, end };
}
async function rangeFromCache(ctx, { cacheName }) {
const { request } = ctx;
const cache = await caches.open(cacheName);
// Cache matching ignores the Range header, so this finds the full 200 entry.
const cached = await cache.match(request);
if (!cached) return fetch(request); // Range header is forwarded unchanged
const blob = await cached.blob(); // empty for opaque entries - cache media with CORS
const range = parseRange(request.headers.get("range"), blob.size);
const headers = new Headers(cached.headers);
headers.set("Accept-Ranges", "bytes");
if (range === null) {
headers.set("Content-Length", String(blob.size));
return new Response(blob, { status: 200, headers });
}
if (range === "unsatisfiable") {
return new Response(null, {
status: 416,
statusText: "Range Not Satisfiable",
headers: { "Content-Range": `bytes */${blob.size}` },
});
}
const slice = blob.slice(range.start, range.end + 1);
headers.set("Content-Range", `bytes ${range.start}-${range.end}/${blob.size}`);
headers.set("Content-Length", String(slice.size));
return new Response(slice, { status: 206, statusText: "Partial Content", headers });
}
// ---------------------------------------------------------------------------
// Navigation handler (uses navigation preload when enabled)
// ---------------------------------------------------------------------------
function navigationFetcher({ event, request }) {
// preloadResponse resolves to undefined when preload is off or unsupported,
// and rejects with a TypeError when the preload hit a network error.
return Promise.resolve(event.preloadResponse).then((preloaded) => preloaded ?? fetch(request)); // (4)!
}
// ---------------------------------------------------------------------------
// Offline fallbacks by destination
// ---------------------------------------------------------------------------
async function offlineFallback({ request }) {
switch (request.destination) {
case "document":
case "iframe":
case "frame": {
const page = await caches.match(OFFLINE_PAGE);
return (
page ??
new Response("<!doctype html><title>Offline</title><h1>You are offline</h1>", {
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
})
);
}
case "image":
return (await caches.match(OFFLINE_IMAGE)) ?? Response.error();
case "":
// fetch()/XHR from the page: answer in a shape the app can handle.
if ((request.headers.get("Accept") || "").includes("json")) {
return json({ error: "offline" }, { status: 503, headers: { "Retry-After": "30" } });
}
return Response.error();
default:
return Response.error();
}
}
// ---------------------------------------------------------------------------
// Route table - first match wins, so order from most to least specific.
// ---------------------------------------------------------------------------
const router = new Router()
.add("navigations", isNavigation, (ctx) =>
networkFirst(ctx, { cacheName: CACHES.pages, timeoutMs: 4000, fetcher: navigationFetcher }),
)
.add("precached shell", all(method("GET"), isPrecached), (ctx) =>
cacheFirst(ctx, { cacheName: CACHES.shell }),
)
.add("hashed assets", all(method("GET"), path("/assets/*")), (ctx) =>
cacheFirst(ctx, { cacheName: CACHES.assets }),
)
.add("cached media", all(method("GET"), hasRange, destination("audio", "video"), path("/media/*")), (ctx) =>
rangeFromCache(ctx, { cacheName: CACHES.media }),
)
.add("images", all(method("GET"), destination("image"), path("/*")), (ctx) =>
cacheFirst(ctx, { cacheName: CACHES.images, maxEntries: 200 }),
)
.add("api reads", all(method("GET"), path("/api/*")), (ctx) =>
networkFirst(ctx, { cacheName: CACHES.api, timeoutMs: 3000 }),
)
.add("web fonts", all(method("GET"), destination("font"), origin("https://fonts.gstatic.com")), (ctx) =>
cacheFirst(ctx, { cacheName: CACHES.fonts, maxEntries: 30 }),
)
.add("stylesheets", all(method("GET"), destination("style")), (ctx) =>
staleWhileRevalidate(ctx, { cacheName: CACHES.assets }),
)
.setCatchHandler(offlineFallback);
// ---------------------------------------------------------------------------
// Event wiring - listeners must be added during the initial script evaluation.
// ---------------------------------------------------------------------------
self.addEventListener("fetch", (event) => {
const { request } = event;
// fetch() would throw for this combination; let the browser handle it.
if (request.cache === "only-if-cached" && request.mode !== "same-origin") return;
const responsePromise = router.handle(event);
if (responsePromise) {
event.respondWith(responsePromise); // (5)!
}
// No match: return without calling respondWith() and the browser goes to
// the network as if this worker did not exist.
});
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHES.shell);
await Promise.all(
PRECACHE_URLS.map(async (url) => {
const request = new Request(url, { cache: "reload" }); // bypass the HTTP cache
const response = await fetch(request);
if (!response.ok) throw new Error(`Precache of ${url} failed: ${response.status}`);
// A redirected response cannot satisfy a navigation (redirect mode "manual").
await cache.put(request, response.redirected ? cleanResponse(response) : response); // (6)!
}),
);
})(),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
const keep = new Set(Object.values(CACHES));
const names = await caches.keys();
await Promise.all(names.filter((name) => !keep.has(name)).map((name) => caches.delete(name)));
})(),
);
});
- Returning
nullsynchronously is how the router says "no route": the listener then does not callrespondWith()and the request falls through to the network untouched. - The catch handler runs for every failed route, so the promise given to
respondWith()never rejects.Response.error()is the deliberate last resort for subresources with no sensible fallback. - Registering the network promise as a lifetime promise keeps the worker alive for the background cache update when the timeout serves the cached copy first. It also avoids Chromium's "navigation preload request was cancelled" warning, because the preload promise is part of
network. Promise.resolve()makes the code work in engines whereevent.preloadResponsedoes not exist (it is thenundefined). A rejected preload (network failure) rejectsnetwork, which sends the strategy to its cached copy and then to the offline fallback.respondWith()is called synchronously during dispatch with a promise. Everything asynchronous happens inside that promise.cache.put()stores the URL list, so a redirected response cached here would later fail as the answer to a navigation. The clean copy has an empty URL list.
How the route table behaves:
| Route | Matches | Strategy | Cache | When offline |
|---|---|---|---|---|
| navigations | GET with mode navigate | Network first (preload), 4 s timeout | pages | Cached page, then /offline.html |
| precached shell | /, /offline.html, app CSS and JS | Cache first | shell-<version> | Served from cache |
| hashed assets | /assets/* | Cache first | assets-<version> | Served from cache or network error |
| cached media | Same-origin /media/* range requests | 206 from cache, else network | media | Cached files only |
| images | Same-origin image destination | Cache first, 200 entries | images | Placeholder SVG |
| api reads | GET /api/* | Network first, 3 s timeout | api | Cached JSON, then 503 JSON |
| web fonts | font from fonts.gstatic.com | Cache first, 30 entries | fonts | Fallback font |
| stylesheets | Any other style request | Stale-while-revalidate | assets-<version> | Cached copy |
| everything else | - | Falls through to the network | - | Browser default |
Ways to extend it without changing its shape:
- Add the POST outbox from An offline outbox for API writes as a route with
method("POST"). - Add a streamed article route that returns
streamedArticle(ctx.event). - Declare the fall-through paths (
/admin/*, third-party analytics) as static routes with sourcenetworkin theinstallhandler so they never wake the worker in Chrome 123+ and Safari 27 (see Static Routing API). - Replace the hand-written strategies with Workbox's when you need expiration by age, broadcast updates or background sync plugins (see Workbox Fundamentals).
Browser support¶
Support data as of September 2026. For live data, check MDN's FetchEvent compatibility table and caniuse.com's service worker entry.
| Feature | Chrome / Edge | Firefox | Safari (macOS and iOS) |
|---|---|---|---|
fetch event, respondWith() | ✅ 42 | ✅ 44 | ✅ 11.1 |
clientId | ✅ 49 | ✅ 45 | ✅ 11.1 |
resultingClientId | ✅ 72 | ✅ 65 | ✅ 16 |
handled | ✅ 86 | ✅ 84 | ✅ 16 |
preloadResponse (navigation preload) | ✅ 59 | ✅ 99 | ✅ 15.4 |
replacesClientId | ❌ | ❌ | ❌ (as targetClientId: 11.1 to 15.x) |
Network error for cors response to same-origin request | ✅ 66 | ✅ 59 | ❌ |
Response.json() | ✅ 105 | ✅ 115 | ✅ 17 |
Response.redirected | ✅ 57 | ✅ 49 | ✅ 10.1 |
URLPattern | ✅ 95 | ✅ 142 | ✅ 26 |
Request.body stream getter | ✅ 105 | ❌ | ✅ 11.1 |
Streaming request bodies (duplex: "half") | ✅ 105 | ❌ | ❌ |
Range preserved by fetch(event.request) | ✅ 87 | ⚠️ | ✅ |
| Response URL used as final URL of subresources | ❌ | ✅ 59 | ❌ |
Static routing (InstallEvent.addRoutes()) | ✅ 123 | ❌ | ✅ 27 |
⚠️ web.dev reported in October 2020 that Firefox dropped the Range header when a service worker passed a media request to fetch(); falling through (not calling respondWith()) avoids the issue in every engine.
Chromium's skipping of no-op fetch handlers (warnings from Chrome 112, skipping from Chrome 115) is an engine optimization rather than a web-exposed feature, so it is not listed. Edge versions before 79 used the EdgeHTML engine, which supported service workers from Edge 17; all current Edge versions match Chrome.
Common pitfalls¶
- An
asynclistener that callsrespondWith()after anawait. ThrowsInvalidStateError, and the request falls through. CallrespondWith()synchronously with a promise. respondWith(caches.match(request))without a fallback. A cache miss resolves toundefined, which becomes a network error.fetch(event.request.url)instead offetch(event.request). Loses mode, credentials, method, body, headers and redirect mode; turnsno-corsimages into failing CORS requests.- Reading a request body and then forwarding the request. "Cannot construct a Request with a Request object that has already been used." Clone first.
- Cloning a response after returning it.
clone()throws once the body is used or locked; clone synchronously beforereturn. - Serving redirected responses to navigations. Precache final URLs or copy responses with
cleanResponse(). - Treating
!response.okas "offline" for navigations. Breaks every redirect (opaqueredirecthasok === false) and hides real 404 pages behind stale content. - Caching opaque responses with cache-first. Errors get cached forever and each entry costs about 7 MiB of quota on average in Chrome.
- Awaiting
clients.get(event.resultingClientId)insiderespondWith(). Deadlocks the navigation; usewaitUntil(). - A catch-all
respondWith(fetch(event.request)). Adds latency and failure modes to requests you do not change; fall through instead. - A no-op
fetchlistener for installability. Not needed for menu installs since Chrome 108 (Android) and 112 (desktop), and current Chromium's install promotion has no service worker check either; remove it. - Adding
fetchlisteners asynchronously. Listeners must be registered during the initial script evaluation. - Rebuilding media requests with
new Request(request, init). Drops theRangeheader fromno-corsrequests. - Calling
event.preventDefault()by habit. WithoutrespondWith()it produces a network error instead of falling through. - Caching per-user API responses without a logout plan. Cache Storage is shared by every user of the browser profile on that origin; clear user-specific caches on logout (see Privacy & Storage Partitioning).
- Synthesized redirects that loop. Worker-generated redirects for subresources come back to the worker; guard against redirecting a URL to itself.
More cross-cutting mistakes are collected in Pitfalls & Anti-Patterns.
Debugging¶
Chrome and Edge DevTools. The Application panel's Service workers pane has three switches that matter for fetch handling: Offline (network emulation), Update on reload, and Bypass for network, which "bypasses the service worker and forces the browser to go to the network for requested resources". Its Network requests link opens the Network panel filtered with is:service-worker-intercepted. In the Network panel, the Timing tab of an intercepted request shows ServiceWorker Preparation (worker start-up) and Request to ServiceWorker (dispatch), which is where you see the cost described in The cost of a fetch handler; requests made by the worker itself are marked with a gear icon. chrome://serviceworker-internals lists every registration with start, stop and inspect controls. See Browser DevTools for a full tour.
Firefox. about:debugging#/runtime/this-firefox lists registered workers with Inspect (a dedicated console and debugger for the worker) and Unregister. Worker console output, including your route logs, appears in that inspector rather than in the page's console.
Safari. Open the worker's inspector from the Develop menu's Service Workers submenu (turn on the web developer features in Safari's settings first). For iOS and iPadOS, connect the device and use the same menu under the device's name.
Console messages to recognize:
| Message (Chromium) | Meaning |
|---|---|
... resulted in a network error response: the promise was rejected. | Your respondWith() promise rejected; add a fallback. |
... an object that was not a Response was passed to respondWith(). | Usually undefined from a cache miss. |
... a redirected response was used for a request whose redirect mode is not "follow". | A redirected cached response answered a navigation. |
The event handler is already finished. | respondWith() called asynchronously. |
Fetch event handler is recognized as no-op. ... | Remove the empty listener. |
The service worker navigation preload request was cancelled before 'preloadResponse' settled. ... | Preload enabled but not used; see Navigation Preload. |
Instrument your own routes. During development, add an X-SW-Route header with withHeaders() so the Network panel shows which route produced each response, and read PerformanceResourceTiming.workerStart in the page to measure start-up in the field (see Measuring Performance).
Further reading¶
On this site
- Service worker lifecycle: when a worker controls a page and how long events may run
- Navigation Preload: removing worker start-up from the navigation critical path
- Static Routing API: declaring routes the browser evaluates without your code
- Caching Strategies: cache first, network first and stale-while-revalidate in depth
- Cache Storage API: matching rules,
VaryandignoreSearch - Streaming Responses: composing responses from streams
- Service Worker Security: what a compromised worker can do and how to limit it
- Advanced Workbox: routing, plugins and range requests with Workbox
External references
- Service Workers specification: FetchEvent and Handle Fetch
- Fetch Standard: request modes, destinations, the
Requestconstructor and HTTP fetch - MDN: FetchEvent and FetchEvent.respondWith()
- MDN: Request.destination and Request.mode
- MDN: URLPattern and Response.json()
- Chrome for Developers: Caching resources during runtime (opaque responses)
- web.dev: Handle range requests in a service worker
- Phil Nash: Service workers, beware Safari's range request
- Chrome Platform Status: Skip service worker no-op fetch handler
- Chrome for Developers: Revisiting Chrome's installability criteria
- WHATWG Fetch issue 573: redirected responses and service workers