Service Worker Static Routing API¶
The Service Worker Static Routing API lets a service worker declare, at install time, which requests should skip its JavaScript entirely and go straight to the network or to Cache Storage, and which should be raced against its fetch handler. The browser evaluates these rules before starting the worker, so matched requests no longer pay the startup cost that otherwise sits in front of every intercepted request. It shipped in Chrome 123 and Safari 27.0; Mozilla's standards position is positive, but Firefox has no implementation yet, so routes are always a progressive enhancement on top of a complete fetch handler.
Key takeaways
- Call
event.addRoutes(rules)inside theinstallevent. Rules belong to that worker version, take effect when it becomes active, are evaluated in order, and the first match wins. - Conditions (
urlPattern,requestMethod,requestMode,requestDestination,runningStatus) are ANDed;orandnotmust stand alone. The spec keeps a worker below 1,024 conditions in total and limits conditions to 10 levels deep. - Sources:
"network","cache"or{ cacheName }(a miss goes to the network, never to your fetch handler),"fetch-event", and"race-network-and-fetch-handler"(GET only). - String and dictionary
urlPatterns resolve against the worker script URL and inherit its origin; a constructedURLPatternobject does not, sonew URLPattern({ pathname: "/api/*" })matches every origin. Regular-expression groups are rejected. - Routing a cold worker's navigations to
"network"skips startup but also skips your offline fallback;"race-network-and-fetch-handler"keeps the fallback. - Measure with
workerRouterEvaluationStart,workerCacheLookupStart,workerMatchedRouterSourceandworkerFinalRouterSourcein Resource and Navigation Timing (Chromium names the last twoworkerMatchedSourceTypeandworkerFinalSourceType).
Why static routing exists: the cost of starting a service worker¶
Once a service worker with a fetch listener controls a page, the browser must hand every in-scope navigation and every subresource request from that page to the worker. If the worker is stopped, which is the normal state after about 30 seconds of inactivity in Chromium and Firefox, the browser first has to start it: create the worker thread (and possibly a process), load the script from the service worker script cache, run its top-level code, and only then dispatch a FetchEvent. The handler may decide in a microsecond that it wants nothing to do with the request, yet the request still waited for all of that.
Plenty of traffic never benefits from the worker:
- API calls the worker never caches, and POST, PUT or DELETE requests;
- fingerprinted static assets that the worker would answer with a plain cache lookup;
- large media files and range requests;
- third-party scripts, fonts and analytics beacons;
- HTML for network-first pages, when the worker is cold and the network is fast.
Before this API, the mitigations were all partial:
| Technique | What it saves | What it costs |
|---|---|---|
Returning without respondWith() | Response handling in the worker | Worker startup and event dispatch still sit on the critical path |
| Navigation preload | Runs the navigation's network request in parallel with worker startup | Navigations only; the response still flows through the handler |
No fetch listener at all | Browsers skip the worker for every request | No offline support, no caching logic |
| A narrower registration scope | Requests outside the scope never touch the worker | Path-prefix granularity only; one scope per registration |
Static routing adds a declarative layer: rules that the browser evaluates on its own, without running any service worker JavaScript, to decide whether a request needs the worker at all.
sequenceDiagram
participant Page
participant Browser
participant SW as Service worker
participant Net as Network
Page->>Browser: fetch("/api/feed")
alt No matching route
Browser->>SW: start worker, evaluate script
Browser->>SW: dispatch fetch event
SW->>Net: fetch(event.request)
Net-->>SW: response
SW-->>Browser: respondWith(response)
else Route matches with source "network"
Browser->>Net: request sent immediately
Net-->>Browser: response
end
Browser-->>Page: response Where the router runs in request handling¶
The rules plug into the Service Workers specification's Handle Fetch algorithm, which Fetch calls for every request that a service worker could intercept. The relevant steps, in order:
- Is the request interceptable? A navigation needs a registration with an active worker for the target URL and must not be a hard reload (Shift plus reload). A subresource request needs a client that is already controlled.
<embed>and<object>requests are never intercepted. Requests that fail these checks never see the router. - Control is established first. For a navigation, the new client's active service worker is set before the rules run. A page whose HTML came from a
"network"route is still a controlled page:navigator.serviceWorker.controlleris set and its subresources are intercepted as usual. - Rules are evaluated only if the active worker registered any. The browser records
workerRouterEvaluationStart, then walks the active worker's rule list in registration order and takes thesourceof the first rule whose condition matches. - The source decides the path.
"network"returns the request to Fetch as if there were no worker;"cache"looks in Cache Storage;"race-network-and-fetch-handler"starts both paths;"fetch-event"and "no rule matched" continue to the normal fetch event path, including navigation preload. - Update checks still happen. For navigations (and subresource requests of a stale registration),
"network"and"cache"routes still trigger the usual soft update check in parallel, so routes never stop your worker from updating.
flowchart TD
R["Request that the active worker could intercept"] --> H{"Rules registered?"}
H -- No --> FE["Start worker if needed and dispatch fetch event"]
H -- Yes --> M{"First matching rule"}
M -- "no match" --> FE
M -- "fetch-event" --> FE
M -- "network" --> N["Network, worker not involved"]
M -- "cache or cacheName" --> C{"Cache Storage hit?"}
C -- "hit" --> CR["Respond from Cache Storage"]
C -- "miss" --> N
M -- "race-network-and-fetch-handler" --> RACE["Network request and fetch event in parallel"]
RACE --> W["First usable response wins"] The addRoutes() method¶
Syntax and IDL¶
[Exposed=ServiceWorker]
interface InstallEvent : ExtendableEvent {
constructor(DOMString type, optional ExtendableEventInit eventInitDict = {});
Promise<undefined> addRoutes((RouterRule or sequence<RouterRule>) rules);
};
dictionary RouterRule {
required RouterCondition condition;
required RouterSource source;
};
dictionary RouterCondition {
URLPatternCompatible urlPattern;
ByteString requestMethod;
RequestMode requestMode;
RequestDestination requestDestination;
RunningStatus runningStatus;
sequence<RouterCondition> _or; // spelled "or" in JavaScript
RouterCondition not;
};
typedef (RouterSourceDict or RouterSourceEnum) RouterSource;
dictionary RouterSourceDict { DOMString cacheName; };
enum RunningStatus { "running", "not-running" };
enum RouterSourceEnum { "cache", "fetch-event", "network", "race-network-and-fetch-handler" };
rules is a single RouterRule or an array of them. Pass an array when you have several rules; extra positional arguments are ignored by WebIDL, so addRoutes(ruleA, ruleB) silently registers only ruleA.
self.addEventListener("install", (event) => {
// Guard: in Firefox (and older engines) addRoutes is undefined, and calling it
// would throw before the rest of the install handler runs.
if (!("addRoutes" in event)) return;
event.addRoutes([
{ condition: { urlPattern: "/api/*" }, source: "network" },
{ condition: { urlPattern: "/assets/*" }, source: { cacheName: "assets-v12" } },
]);
});
Return value, lifetime and failure behavior¶
addRoutes() returns a Promise<undefined> that fulfills once the browser has stored the rules. You do not need to wrap it in waitUntil(): the method adds its own lifetime promise to the install event, exactly "as if event.waitUntil(promise) is called".
That internal lifetime promise always fulfills, even when registering the rules fails. The spec does this deliberately, so a bad rule does not fail installation. Validation errors are reported only through the returned promise, which means an invalid rule set is easy to ship unnoticed. Decide explicitly how strict to be:
self.addEventListener("install", (event) => {
if (typeof event.addRoutes === "function") {
// Routes are an optimization: a failure must not block installation.
event.addRoutes(ROUTES).catch((error) => {
console.error("Static routes rejected", error);
reportToBackend("sw-routes-rejected", error.message);
});
}
event.waitUntil(precache());
});
When and how often you can call it¶
- Only on a real install event, while it is active. Call it synchronously in the
installlistener or from code that runs before the promises you passed towaitUntil()settle. Chromium implements the lifetime extension withwaitUntil()internally, so a call after the event has finished rejects withInvalidStateError, and calling it on a script-constructednew InstallEvent("install")rejects with "Can not call addRoutes on a script constructed InstallEvent." - Any number of times. Each call appends to the worker's rule list. The origin trial version (
registerRouter(), Chrome 116) could be called once; the rename toaddRoutes()made multiple calls possible so that libraries loaded withimportScripts()can add their own routes next to yours. Order across calls is the order in which the browser processed them, so register the most specific rules first. - Never changed after installation. Rules are stored with the worker version. There is no API to read, remove or replace them; a new worker version starts with an empty list and must register its rules again. To change routing, ship a new service worker.
- Effective only for the active worker. A waiting worker's rules do nothing until it activates. During an update, requests follow the old active worker's rules.
Validation errors¶
The browser validates each rule when you call addRoutes(). Every failure below rejects the returned promise with a TypeError; the messages are Chromium's.
| Problem | Example | Chromium message |
|---|---|---|
| Condition object is empty or missing | { condition: {}, source: "network" } | "At least one condition must be set, but no condition has been set to the rule." |
or combined with other keys | { or: [...], requestMethod: "GET" } | "Cannot set other conditions when the or condition is specified" |
not combined with other keys | { not: {...}, urlPattern: "/x" } | "Cannot set other conditions when the not condition is specified" |
| Invalid method token | requestMethod: "GET POST" | "'GET POST' is not a valid HTTP method." |
| Forbidden method | requestMethod: "CONNECT" (also TRACE, TRACK) | "'CONNECT' HTTP method is unsupported." |
| Unparseable pattern or regexp groups | urlPattern: "/items/:id(\\d+)" | URLPattern parse or "regexp groups" error |
| Unknown enum value | requestMode: "nav", source: "offline" | WebIDL enum conversion error |
fetch-event source without a fetch listener | worker has no fetch handler | "fetch-event source is specified without a fetch handler" |
Race source without a fetch listener | same | "race-network-and-fetch-event source is specified without a fetch handler" |
Source dictionary without cacheName | source: {} | "Got a dictionary for source but no field is set" |
| Nesting too deep | A leaf condition wrapped in 10 nested or/not levels | "Conditions are nested too much" |
One more failure is reported asynchronously: if Chromium's browser process cannot compile a URL pattern that passed the renderer-side checks, the returned promise rejects with the TypeError "Could not parse provided condition regex" after the rules were sent for storage.
The fetch listener check uses the set of event types the worker registered during its initial script evaluation, the same set browsers use to skip dispatching events a worker cannot handle. Adding the listener later in the install handler does not satisfy it.
Limits on rules and conditions¶
The spec's Check Router Registration Limit algorithm protects the browser from pathological rule sets, because rules are evaluated for every intercepted request:
- Total conditions: a budget of 1,024 conditions across all of the worker's rules, counting every nested condition inside
orandnot. The counter is decremented per condition and the quota is exceeded when it reaches zero, so stay below 1,024. - Nesting depth: the top-level condition of a rule counts as depth 1, and each
orornotadds a level; a condition at depth 11 (a leaf wrapped in ten nestedor/notoperators) exceeds the limit. Chromium enforces the same bound (kServiceWorkerRouterConditionMaxRecursionDepth = 10) when you calladdRoutes(). - Chromium per-call cap: Chromium additionally rejects a single
addRoutes()array containing 256 or more rules ("Too many router rules.").
Safari 27.0's release notes list a fix that makes static routing "enforce limitation checks as required by the specification", so both shipping engines apply these limits. In practice, a real app needs a handful of rules; if you are approaching the limits, collapse rules with wildcards.
Conditions reference¶
A RouterCondition matches a request when all of its present members match. The members:
| Member | Type | Matches when |
|---|---|---|
urlPattern | URLPattern, URLPattern init dictionary or pattern string | The request URL matches the pattern |
requestMethod | string | The request method equals the normalized value |
requestMode | "navigate", "same-origin", "no-cors", "cors" | request.mode is equal |
requestDestination | Fetch RequestDestination value | request.destination is equal |
runningStatus | "running" or "not-running" | The worker's event loop is (or is not) running at evaluation time |
or | array of conditions | Any nested condition matches; must be the only member |
not | condition | The nested condition does not match; must be the only member |
urlPattern: how patterns resolve against the worker URL¶
urlPattern accepts any of the three URLPatternCompatible forms, and the form changes the result:
- A string or dictionary is converted with the worker's script URL as
baseURL(URLPattern's "build a URL pattern from a Web IDL value" algorithm). Components to the left of the first one you specify (protocol, hostname, port) are inherited from the script URL; components to the right (search, hash) become wildcards. - A constructed
URLPatternobject is used as is. Every component you did not specify is a wildcard, including protocol and hostname, so it matches every origin.
The table shows what the browser actually matches (resolved with the URLPattern algorithm):
urlPattern value | Worker script | Effective pattern | Consequence |
|---|---|---|---|
"/articles/*" | https://example.com/sw.js | https://example.com/articles/*, any search and hash | Same-origin articles; does not match /articles without the trailing slash |
"/articles{/*}?" | https://example.com/sw.js | pathname /articles{/*}? | Matches /articles and everything below it |
"*.png" | https://example.com/sw.js | pathname /*.png | Every same-origin PNG at any depth (* crosses /) |
"*.png" | https://example.com/app/sw.js | pathname /app/*.png | Only PNGs under /app/: relative paths resolve against the script's directory |
{ pathname: "/api/*" } | https://example.com/sw.js | https://example.com/api/* | Same as the string form |
new URLPattern({ pathname: "/api/*" }) | any | any protocol, host and port, pathname /api/* | Also matches https://third-party.example/api/... |
"https://cdn.example.net/*" | any | absolute | Only that origin |
"/search?q=*" | https://example.com/sw.js | pathname /search, search q=* | Include a search component to constrain queries |
Rules for what the pattern may contain:
- No regular-expression groups. The spec rejects any pattern whose
hasRegExpGroupsis true, because running author-supplied regular expressions in the browser's routing path is a security and performance risk. That forbids:id(\\d+),(js|css)and:ext(js|css). - Named groups and wildcards are fine.
:id(which uses the default segment matcher),*, and non-regexp groups with modifiers such as{/*}?are allowed. - Braces are not alternation.
"/static/*.{js,css}"is the literal suffix.js,css. Express alternatives withor. - Case-insensitive matching needs a
URLPatternobject with{ ignoreCase: true }. Because objects skip the base URL, pin the origin yourself with abaseURLin the init dictionary:
const docsPattern = new URLPattern(
{ pathname: "/Docs/*", baseURL: self.location.origin }, // inherit protocol, host and port
{ ignoreCase: true },
);
self.addEventListener("install", (event) => {
if (!("addRoutes" in event)) return;
// Matches https://example.com/docs/intro and /DOCS/intro, but not other origins.
event
.addRoutes({ condition: { urlPattern: docsPattern }, source: "network" })
.catch((error) => console.error("Static routes rejected", error));
});
URLPattern itself is available in Chrome 95, Firefox 142 and Safari 26, so it exists in every browser that supports static routing.
requestMethod¶
The value must be a valid HTTP method token and must not be a forbidden method (CONNECT, TRACE, TRACK). The browser normalizes it using Fetch's rules, which uppercase only DELETE, GET, HEAD, OPTIONS, POST and PUT. Any other method is compared byte for byte, so requestMethod: "patch" never matches a PATCH request (and would match a request sent with fetch(url, { method: "patch" }), which really does send lowercase patch). Always write methods in uppercase.
requestMode¶
"navigate" matches navigations (top-level and iframe documents), "same-origin", "no-cors" and "cors" match subresources by the mode Fetch assigned. For reference: <img>, <script> and <link rel=stylesheet> without a crossorigin attribute use no-cors; fetch() defaults to cors; module scripts and fonts use cors.
requestDestination¶
Any RequestDestination value: "" (for fetch() and XHR), "audio", "audioworklet", "document", "embed", "font", "frame", "iframe", "image", "json", "manifest", "object", "paintworklet", "report", "script", "sharedworker", "style", "text", "track", "video", "worker" or "xslt". "embed" and "object" are accepted but can never match, because those requests never reach a service worker. Top-level navigations have destination "document"; iframes have "iframe".
runningStatus¶
"running" matches only when the worker's event loop is running at the moment the rule is evaluated; "not-running" matches when it is stopped. A worker that is still starting up is not running yet. The condition exists to make one decision: "is the startup cost going to be paid for this request?" If the worker is already warm, dispatching to it is cheap; if it is cold, going to the network directly might be faster.
The status is sampled per request and changes constantly: the first navigation after a period of inactivity sees "not-running"; once one of its subresource requests has started the worker (by falling through to the fetch event), later requests from the page see "running".
Combining conditions: AND, or, not¶
Multiple members in one condition are ANDed. or and not must be the only member of their condition object, so compose them by nesting:
event.addRoutes([
{
// Images from our CDN, from /img/ or from /icons/.
condition: {
or: [
{ urlPattern: "https://cdn.example.com/*", requestDestination: "image" },
{
or: [
{ urlPattern: "/img/*", requestDestination: "image" },
{ urlPattern: "/icons/*", requestDestination: "image" },
],
},
],
},
source: { cacheName: "images" },
},
]);
"Except" clauses are best expressed as an earlier rule, not as not inside a larger condition, because first-match-wins ordering is easier to read and to extend:
event.addRoutes([
// Exception first: fresh images always hit the network. A dictionary is used
// because in a string, "*?" would be parsed as an optional-wildcard modifier.
{ condition: { urlPattern: { pathname: "/img/*", search: "fresh=1" } }, source: "network" },
// General rule second.
{ condition: { urlPattern: "/img/*", requestDestination: "image" }, source: { cacheName: "images" } },
]);
Use not for rules that are naturally negative, such as "everything that is not same-origin" or "every method except GET".
Sources reference¶
| Source | Worker started? | On miss or failure | Methods | Typical use |
|---|---|---|---|---|
"network" | No | Normal network error | Any | APIs, uploads, third-party, media |
"cache" | No | Falls back to the network | GET only (others miss) | Precached immutable assets |
{ cacheName } | No | Falls back to the network, also when the cache does not exist | GET only (others miss) | Versioned asset caches |
"fetch-event" | Yes | Whatever your handler does | Any | Exceptions carved out before broader rules |
"race-network-and-fetch-handler" | Yes, in parallel | The other contender's result | GET (other methods go to the fetch event) | Network-first HTML without waiting for startup |
"network"¶
The request continues exactly as if no service worker existed for it: the HTTP cache, cookies, CORS and redirects behave normally, and the worker is not started for it. For navigations the browser still performs its soft update check in parallel. The resulting page is controlled.
"cache" and¶
The browser looks the request up in Cache Storage for the worker's origin without starting the worker. Chromium performs the equivalent of caches.match(request) (or cache.match() on the named cache) with default options, which has consequences:
- Exact URL match.
ignoreSearchisfalse, so/app.js?v=3does not match a cached/app.js. There is no way to passignoreSearch,ignoreVaryorignoreMethod. Varyis honored. A cached response withVary: Accept-Languagematches only requests with an equalAccept-Languageheader.- Only GET requests can hit. Non-GET requests never match a cached entry and go to the network.
- Without
cacheName, all caches are searched in the order they were created, likecaches.match(). WithcacheName, only that cache is searched, and a missing cache simply means "miss". - A miss goes to the network, not to your fetch handler. There is no way to express "cache, then worker". If a miss needs custom logic, use
"fetch-event"for that route. - Opaque responses are policy-checked. An opaque cached response is subject to a Cross-Origin-Resource-Policy check against the worker's origin, and Chromium applies the worker's Cross-Origin-Embedder-Policy to cache lookups, as it does for
caches.match()in the worker.
"cache" routes are the best fit for fingerprinted, immutable assets that you precache during install (see Precaching & Runtime Caching), because the URL is stable and never needs revalidation.
"fetch-event"¶
Dispatches the request to your fetch handler, exactly as if no rule had matched. Its value is in rule ordering: a "fetch-event" rule placed before a broad "network" rule carves out an exception.
event.addRoutes([
// The offline outbox needs the worker (it queues requests when offline)...
{ condition: { urlPattern: "/api/outbox/*" }, source: "fetch-event" },
// ...but the rest of the API never does.
{ condition: { urlPattern: "/api/*" }, source: "network" },
]);
An explicit "fetch-event" match also opts a navigation out of one optimization: the spec permits a browser to speculatively start a navigation's network request in parallel with the fetch event when navigation preload is off and no rule matched, but not when a rule explicitly chose "fetch-event". Chromium has been developing this behavior as "ServiceWorkerAutoPreload" (see its chromestatus entry).
"race-network-and-fetch-handler"¶
For GET requests, the browser starts a network request and dispatches the fetch event at the same time, then uses whichever produces a usable response first. The spec's details matter:
- The network contender only counts if its response has an ok status (200 to 299). A 404 or 500 from the network never "wins"; the browser waits for the handler instead.
- If the handler calls
respondWith()with a response that is not a network error, the browser aborts the network contender (if it is still running). - If the handler does not call
respondWith(), the browser uses the racing network response, whatever its status, instead of sending a second request. - Inside the handler,
fetch(event.request)does not send a duplicate request: the spec keeps the in-flight race response in the worker's race response map and hands a clone of it to a matchingfetch()(Web Platform Tests cover both "handler faster" and "network faster" withfetch()inside the handler). event.preloadResponseresolves toundefinedfor raced requests: the race request replaces navigation preload.- Non-GET requests matched by a race rule skip the race and go to the fetch event.
The practical effect depends on what your handler does. A handler that answers from the cache wins whenever the worker is warm, so the race behaves like "cache first when warm, network when cold", which is only acceptable when cached and fresh responses are interchangeable. A network-first handler that reuses the race request and falls back to the cache or an offline page on failure gives you network-first navigations that never wait for worker startup and still work offline:
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return;
event.respondWith(networkFirstPage(event));
});
async function networkFirstPage(event) {
const cache = await caches.open("pages");
try {
// In race mode this reuses the browser's in-flight race request.
const response = await fetch(event.request);
if (response.ok) event.waitUntil(cache.put(event.request, response.clone()));
return response;
} catch {
// Offline: the network contender produced no ok response, so this result wins.
return (
(await cache.match(event.request)) ??
(await caches.match("/offline.html")) ??
Response.error()
);
}
}
The cost of racing is load: every raced request may reach your server even when the handler wins, and on metered connections the user pays for bytes that are thrown away. Race navigations, not every image.
Recipes for common setups¶
Feature detection and progressive enhancement¶
Firefox does not expose InstallEvent at all (its install event is a plain ExtendableEvent), and older Chromium and Safari versions lack the method. Test for the method on the event and treat routes strictly as an optimization: the fetch handler must produce the same behavior for every route, so browsers without static routing behave identically, just with worker startup on the critical path.
const supportsStaticRouting = typeof InstallEvent !== "undefined" && "addRoutes" in InstallEvent.prototype;
self.addEventListener("install", (event) => {
if (supportsStaticRouting) {
event.addRoutes(ROUTES).catch((error) => console.error("Routes rejected", error));
}
event.waitUntil(precache());
});
Send API calls and mutations straight to the network¶
const ROUTES = [
{ condition: { urlPattern: "/api/*" }, source: "network" },
{ condition: { not: { or: [{ requestMethod: "GET" }, { requestMethod: "HEAD" }] } }, source: "network" },
];
The second rule sends every method other than GET and HEAD to the network, which also covers form posts to arbitrary paths. Do not use it if your worker queues failed POSTs for replay (see Background Sync); route only the endpoints that never need the worker, and keep a "fetch-event" rule for the rest.
Serve fingerprinted assets from a versioned cache¶
Register the route and fill the cache it reads in the same install event:
const ASSET_CACHE = "assets-v12";
const ASSETS = ["/assets/app.3f9c2e.js", "/assets/app.91ab77.css", "/assets/logo.5d1e0a.svg"];
self.addEventListener("install", (event) => {
if ("addRoutes" in event) {
event.addRoutes({
condition: { urlPattern: "/assets/*", requestMethod: "GET" },
source: { cacheName: ASSET_CACHE }, // miss (e.g. a new chunk) -> network
});
}
event.waitUntil(caches.open(ASSET_CACHE).then((cache) => cache.addAll(ASSETS)));
});
Because a miss goes to the network rather than to your handler, lazily loaded chunks that were not precached are fetched from the network every time. If you want them cached at runtime, add a "fetch-event" route for the lazy chunk directory before the cache rule, or precache them.
Skip the worker for navigations while it is cold¶
const ROUTES = [
{ condition: { requestMode: "navigate", runningStatus: "not-running" }, source: "network" },
];
This removes worker startup from the navigation when it would hurt most. The trade-off is severe: when the user is offline and the worker is not running, the navigation fails with the browser's network error page instead of your offline page, because "network" has no fallback. Use it only for apps without an offline requirement for navigations, or prefer the race below.
Race navigations for network-first HTML¶
const ROUTES = [
{ condition: { requestMode: "navigate" }, source: "race-network-and-fetch-handler" },
];
Pair it with the network-first handler shown in the race section. Online, the network request starts immediately; offline, the handler's fallback wins. Consider enabling Navigation Preload as well for browsers without static routing: it has no effect on raced requests but covers everyone else.
Bypass the worker for third-party requests and media¶
const ROUTES = [
// "/*" inherits this worker's origin, so `not` selects every cross-origin request.
{ condition: { not: { urlPattern: "/*" } }, source: "network" },
// Large media and range requests are best left to the browser's media stack.
{ condition: { or: [{ requestDestination: "video" }, { requestDestination: "audio" }] }, source: "network" },
];
A worker that serves an offline shell without a fetch handler¶
Rules with "network" and "cache" sources work in a worker that registers no fetch listener at all (the Web Platform Tests include "The router rule is evaluated without fetch handlers in service worker", and Safari 27.0 fixed routes not matching in that situation). The result is a worker whose request handling is entirely declarative and never costs startup time:
const SHELL = "shell-v3";
const SHELL_URLS = ["/", "/app.js", "/app.css", "/manifest.webmanifest", "/icons/icon-192.png"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(SHELL).then((cache) => cache.addAll(SHELL_URLS)));
if ("addRoutes" in event) {
event.addRoutes({
condition: { or: SHELL_URLS.map((path) => ({ urlPattern: path })) },
source: { cacheName: SHELL },
});
}
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((key) => key.startsWith("shell-") && key !== SHELL).map((key) => caches.delete(key))),
),
);
});
// No fetch listener: every other request bypasses the worker completely.
The limits are the limits of the API: only exact precached URLs work offline, there is no offline fallback page for other routes, and browsers without static routing get no offline support at all. Treat this as a niche pattern for small, static apps.
A complete worker combining routes and a fetch handler¶
const VERSION = "v12";
const ASSET_CACHE = `assets-${VERSION}`;
const PAGE_CACHE = "pages";
const ASSETS = ["/assets/app.3f9c2e.js", "/assets/app.91ab77.css", "/offline.html"];
// Order matters: the first matching rule wins.
const ROUTES = [
{ condition: { urlPattern: "/api/outbox/*" }, source: "fetch-event" },
{ condition: { urlPattern: "/api/*" }, source: "network" },
{ condition: { not: { urlPattern: "/*" } }, source: "network" },
{ condition: { urlPattern: "/assets/*", requestMethod: "GET" }, source: { cacheName: ASSET_CACHE } },
{ condition: { requestMode: "navigate" }, source: "race-network-and-fetch-handler" },
];
self.addEventListener("install", (event) => {
if ("addRoutes" in event) {
event.addRoutes(ROUTES).catch((error) => console.error("Static routes rejected", error));
}
event.waitUntil(caches.open(ASSET_CACHE).then((cache) => cache.addAll(ASSETS)));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys.filter((k) => k.startsWith("assets-") && k !== ASSET_CACHE).map((k) => caches.delete(k)),
);
// Navigation preload helps browsers without static routing.
await self.registration.navigationPreload?.enable();
})(),
);
});
// The handler mirrors every route so that behavior is identical without static routing.
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
const sameOrigin = url.origin === self.location.origin;
if (sameOrigin && url.pathname.startsWith("/api/outbox/")) {
event.respondWith(handleOutbox(event));
return;
}
if (!sameOrigin || url.pathname.startsWith("/api/")) return; // network, no respondWith
if (url.pathname.startsWith("/assets/") && request.method === "GET") {
event.respondWith(caches.open(ASSET_CACHE).then(async (c) => (await c.match(request)) ?? fetch(request)));
return;
}
if (request.mode === "navigate") {
event.respondWith(networkFirstPage(event));
}
});
async function networkFirstPage(event) {
const cache = await caches.open(PAGE_CACHE);
try {
// Raced request (static routing) or navigation preload (fallback path) or a plain fetch.
const response = (await event.preloadResponse) ?? (await fetch(event.request));
if (response.ok) event.waitUntil(cache.put(event.request, response.clone()));
return response;
} catch {
return (
(await cache.match(event.request)) ??
(await caches.match("/offline.html")) ??
Response.error()
);
}
}
async function handleOutbox(event) {
try {
return await fetch(event.request.clone());
} catch {
await queueForSync(event.request); // store in IndexedDB, register a sync
return new Response(JSON.stringify({ queued: true }), {
status: 202,
headers: { "Content-Type": "application/json" },
});
}
}
// Minimal outbox: persist the request in IndexedDB and ask for a sync.
// The Background Sync page covers replay, retries and conflict handling.
function openOutbox() {
return new Promise((resolve, reject) => {
const open = indexedDB.open("outbox", 1);
open.onupgradeneeded = () => open.result.createObjectStore("requests", { autoIncrement: true });
open.onsuccess = () => resolve(open.result);
open.onerror = () => reject(open.error);
});
}
async function queueForSync(request) {
const entry = {
url: request.url,
method: request.method,
headers: [...request.headers],
body: request.method === "GET" || request.method === "HEAD" ? null : await request.arrayBuffer(),
queuedAt: Date.now(),
};
const db = await openOutbox();
try {
await new Promise((resolve, reject) => {
const tx = db.transaction("requests", "readwrite");
tx.objectStore("requests").add(entry);
tx.oncomplete = resolve;
tx.onerror = tx.onabort = () => reject(tx.error);
});
} finally {
db.close();
}
// Background Sync is Chromium-only; elsewhere, replay on the next page load instead.
await self.registration.sync?.register("outbox");
}
Interplay with navigation preload¶
Navigation preload and static routing attack the same problem from different sides: preload overlaps the navigation request with worker startup, routing removes the worker from the path. They coexist, and the spec defines precisely how:
| Navigation outcome | Navigation preload request sent? | event.preloadResponse |
|---|---|---|
| No rule matched | Yes, if enabled | The preload response |
Rule matched "fetch-event" | Yes, if enabled | The preload response |
Rule matched "network" | No, the worker is not involved | Not applicable |
Rule matched "cache" or { cacheName } | No | Not applicable |
Rule matched "race-network-and-fetch-handler" | No, the race request replaces it | undefined; use fetch(event.request) to reuse the race request |
Guidelines that follow from this:
- Keep navigation preload enabled for browsers without static routing and for navigations that fall through to the fetch event.
- In handlers that may run in race mode,
await event.preloadResponsefirst and fall back tofetch(event.request), as in the complete example above. That code path works for all three cases: preload, race and neither. - A navigation routed to
"network"does not carry theService-Worker-Navigation-Preloadheader, because it is not a preload request. If your server varies responses on that header, it serves those navigations the non-preload variant.
Measuring the impact with Resource Timing¶
Static routing adds four fields to PerformanceResourceTiming (and therefore to PerformanceNavigationTiming), defined in the Resource Timing specification and backed by the Service Workers spec's service worker timing info:
| Field | Type | Meaning |
|---|---|---|
workerRouterEvaluationStart | DOMHighResTimeStamp | When the browser started matching the request against the rules; 0 if the worker has no rules |
workerCacheLookupStart | DOMHighResTimeStamp | When a "cache" source started its Cache Storage lookup; 0 otherwise |
workerMatchedRouterSource | string | The source of the first matching rule: "network", "cache", "fetch-event", "race-network-and-fetch-handler", or "" when no rule matched |
workerFinalRouterSource | string | The source that actually produced the response: "network" after a cache miss, the winner of a race, "fetch-event" when the handler answered, or "" |
Chrome 140 shipped the two timestamps together with the router sources under non-standard names, workerMatchedSourceType and workerFinalSourceType. The Microsoft Edge 149 release notes announce the spec names workerMatchedRouterSource and workerFinalRouterSource, but as of September 2026 Chromium's PerformanceResourceTiming IDL still declares only the old names, and MDN's compatibility data lists the spec names in Safari 27 only. Safari 27.0 implements all four spec fields. Feature-detect and read both names:
function routerSources(entry) {
return {
matched: entry.workerMatchedRouterSource ?? entry.workerMatchedSourceType ?? "",
final: entry.workerFinalRouterSource ?? entry.workerFinalSourceType ?? "",
};
}
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const { matched, final } = routerSources(entry);
if (!matched) continue; // no rule matched, or the browser lacks the fields
sendBeaconSafe({
name: entry.name,
type: entry.entryType, // "navigation" or "resource"
matched,
final,
cacheMiss: matched === "cache" && final === "network",
// Time from rule evaluation to first byte of the response.
toFirstByte: entry.workerRouterEvaluationStart
? Math.round(entry.responseStart - entry.workerRouterEvaluationStart)
: null,
duration: Math.round(entry.duration),
});
}
});
observer.observe({ type: "navigation", buffered: true });
observer.observe({ type: "resource", buffered: true });
function sendBeaconSafe(payload) {
try {
navigator.sendBeacon("/rum/router", JSON.stringify(payload));
} catch {
// Reporting must never break the page.
}
}
The cacheMiss flag is the metric to watch for "cache" routes: a high miss rate means you are routing URLs you did not precache (often because of query strings), and every miss is a plain network request. For races, the ratio of final === "network" to final === "fetch-event" tells you which contender usually wins. For whole-page impact, compare navigation responseStart and Core Web Vitals between sessions where workerMatchedRouterSource is set and sessions without static routing (see Measuring Performance).
Debugging static routes in DevTools¶
Chromium's DevTools surface routes in several places:
- Application → Service workers lists the registered router rules for the selected worker version, each with an ID.
- Network panel: hovering the Size cell of a request that matched a rule shows the ID of the matched rule, linked to the rule in the Application panel. The request's Timing tab adds Router evaluation and Cache lookup phases, and expanding the router evaluation row shows "Matched source" and "Actual source", the DevTools equivalents of the two Resource Timing fields.
chrome://serviceworker-internalsshows every registration's rules, which is handy when DevTools is attached to a different context.- Console of the worker shows your own logging of a rejected
addRoutes()promise. Nothing else reports invalid rules, so always log rejections.
A quick check from the page's console confirms what happened to the current document:
const [nav] = performance.getEntriesByType("navigation");
({
matched: nav.workerMatchedRouterSource ?? nav.workerMatchedSourceType,
final: nav.workerFinalRouterSource ?? nav.workerFinalSourceType,
controlled: Boolean(navigator.serviceWorker.controller),
});
When a rule does not seem to apply, check in this order: is the page controlled (a hard reload disables interception); is the worker with the rules the active one (not waiting); did addRoutes() reject; does an earlier rule match first; and does the pattern resolve the way you expect against the worker's script URL (the table above covers the usual surprises). The general DevTools workflow is covered in Browser DevTools.
Limitations and gotchas¶
- Install-time and immutable. You cannot add, inspect or remove rules after installation, or change them at runtime based on user settings. Changing routes means shipping a new worker.
- No rewriting, no custom fallbacks. A route cannot map
/app/settingsto a cached/index.html, add headers, or fall back from cache to your handler. SPA "serve the shell for every route" logic still needs the fetch handler. - No stale-while-revalidate. Cache routes never update the cache. Pair them with immutable, fingerprinted URLs.
"network"for cold navigations breaks offline pages. Only"race-network-and-fetch-handler"and"fetch-event"keep your offline fallback.- Constructed
URLPatternobjects match every origin. Prefer strings and dictionaries, or setbaseURL. - Relative patterns resolve against the script's directory. A worker at
/app/sw.jsturns"*.png"into/app/*.png. - Trailing slashes matter.
"/articles/*"does not match/articles. *?in a string pattern is a modifier, not a query."/img/*?fresh=1"parses as a single pathname pattern; write{ pathname: "/img/*", search: "fresh=1" }, or escape the?as\\?inside a JavaScript string literal.- Unguarded calls break other browsers. Calling
event.addRoutes()where it does not exist throws aTypeErrorin the install listener, and any code after it (such as your precachewaitUntil()) never runs. An uncaught exception does not fail installation, so the worker installs without its precache. Always feature-detect. - Lowercase non-standard methods never match. Write
"PATCH", not"patch". - Invalid rules fail silently unless you log the rejected promise.
- Races add server load and are limited to GET.
- Firefox has no support. Everything must also work through the fetch handler, at the cost of worker startup in that browser.
Browser support and standards status¶
| Feature | Chrome / Edge | Firefox | Safari (macOS, iOS, iPadOS) |
|---|---|---|---|
InstallEvent.addRoutes() with urlPattern, requestMethod, requestMode, requestDestination, runningStatus, or | ✅ 123 | ❌ | ✅ 27.0 |
Sources "network", "cache", { cacheName }, "fetch-event", "race-network-and-fetch-handler" | ✅ 123 | ❌ | ✅ 27.0 |
not condition | ✅ 1271 | ❌ | ✅ 27.0 |
workerRouterEvaluationStart, workerCacheLookupStart | ✅ 140 | ❌ | ✅ 27.0 |
workerMatchedRouterSource, workerFinalRouterSource | ⚠️2 | ❌ | ✅ 27.0 |
Origin trial registerRouter() | 🧪 116 (replaced by addRoutes()) | ❌ | ❌ |
Support data as of September 2026. Opera and Samsung Internet follow their Chromium version. Check live data on MDN's addRoutes() compatibility table and the chromestatus entry.
Standards status. The API is specified in the W3C Service Workers specification (the InstallEvent.addRoutes() section and the router algorithms in Handle Fetch), with the timing fields in Resource Timing. It started as a WICG explainer that evolved from an earlier declarative routing proposal in the Service Workers repository. Mozilla's standards position is positive and WebKit's position is support; Safari 27.0 (September 2026, on macOS 27, iOS 27, iPadOS 27 and visionOS 27, and available for macOS 26 Tahoe and macOS 15 Sequoia) is the second engine to ship it. Firefox's implementation bug remains open, so Gecko-based browsers still route every request through the fetch handler.
Further reading¶
On this site
- Handling Fetch Events: the handler your routes must mirror
- Navigation Preload: the other way to hide worker startup
- Service Worker Lifecycle: when routes are installed and become active
- Caching Strategies: which strategies routes can and cannot express
- Precaching & Runtime Caching: filling the caches that routes read
- Loading Performance: where worker startup fits in the loading timeline
- Messaging & the Clients API: talking to the worker once it is running
External references
- Service Workers specification:
InstallEvent.addRoutes()and router algorithms - MDN:
InstallEvent.addRoutes() - Chrome for Developers: Use the Service Worker Static Routing API
- WICG explainer: ServiceWorker static routing API
- WICG explainer: Resource Timing fields for static routing
- WHATWG URL Pattern Standard
- WebKit: WebKit Features for Safari 27.0
- Apple: Safari 27 Release Notes
- Mozilla standards position on static routing
-
chromestatus records Chrome 127 for the
notcondition; the Intent to Ship originally targeted 126. ↩ -
Chromium (140+) exposes the router sources as
workerMatchedSourceTypeandworkerFinalSourceType. The Edge 149 release notes list the spec names, but Chromium's IDL and MDN's compatibility data do not (September 2026), so treat them as Safari-only until you see them in your own field data. ↩