Skip to content

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 the install event. 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; or and not must 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 constructed URLPattern object does not, so new 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, workerMatchedRouterSource and workerFinalRouterSource in Resource and Navigation Timing (Chromium names the last two workerMatchedSourceType and workerFinalSourceType).

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:

  1. 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.
  2. 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.controller is set and its subresources are intercepted as usual.
  3. 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 the source of the first rule whose condition matches.
  4. 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.
  5. 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

Service Workers specification, InstallEvent (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.

sw.js
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:

sw.js
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());
});
sw.js
self.addEventListener("install", (event) => {
  if (typeof event.addRoutes === "function") {
    // A rejected route set now fails the install, so the previous
    // worker (and its routes) stays active until the next update.
    event.waitUntil(event.addRoutes(ROUTES));
  }
  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 install listener or from code that runs before the promises you passed to waitUntil() settle. Chromium implements the lifetime extension with waitUntil() internally, so a call after the event has finished rejects with InvalidStateError, and calling it on a script-constructed new 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 to addRoutes() made multiple calls possible so that libraries loaded with importScripts() 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 or and not. 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 or or not adds a level; a condition at depth 11 (a leaf wrapped in ten nested or/not operators) exceeds the limit. Chromium enforces the same bound (kServiceWorkerRouterConditionMaxRecursionDepth = 10) when you call addRoutes().
  • 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 URLPattern object 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 hasRegExpGroups is 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 with or.
  • Case-insensitive matching needs a URLPattern object with { ignoreCase: true }. Because objects skip the base URL, pin the origin yourself with a baseURL in the init dictionary:
sw.js
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:

sw.js
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:

sw.js
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. ignoreSearch is false, so /app.js?v=3 does not match a cached /app.js. There is no way to pass ignoreSearch, ignoreVary or ignoreMethod.
  • Vary is honored. A cached response with Vary: Accept-Language matches only requests with an equal Accept-Language header.
  • 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, like caches.match(). With cacheName, 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.

sw.js
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:

  1. 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.
  2. 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).
  3. If the handler does not call respondWith(), the browser uses the racing network response, whatever its status, instead of sending a second request.
  4. 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 matching fetch() (Web Platform Tests cover both "handler faster" and "network faster" with fetch() inside the handler).
  5. event.preloadResponse resolves to undefined for raced requests: the race request replaces navigation preload.
  6. 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:

sw.js
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.

sw.js
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

sw.js
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:

sw.js
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

sw.js
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

sw.js
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

sw.js
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:

sw.js
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

sw.js
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.preloadResponse first and fall back to fetch(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 the Service-Worker-Navigation-Preload header, 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:

router-rum.js
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-internals shows 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:

DevTools console
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/settings to 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 URLPattern objects match every origin. Prefer strings and dictionaries, or set baseURL.
  • Relative patterns resolve against the script's directory. A worker at /app/sw.js turns "*.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 a TypeError in the install listener, and any code after it (such as your precache waitUntil()) 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

External references


  1. chromestatus records Chrome 127 for the not condition; the Intent to Ship originally targeted 126. ↩

  2. Chromium (140+) exposes the router sources as workerMatchedSourceType and workerFinalSourceType. 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. ↩