Injecting Resource Hints from a Service Worker

Your service worker knows exactly which font, hero image and API origin the next view needs, and then has nowhere to put that knowledge: document is undefined in worker scope, document.head.appendChild() throws inside the fetch handler, and the assets you meant to warm still show full DNS, TLS and transfer time in the waterfall.

Root Cause: Hints Belong to a Document, and a Worker Has None

ServiceWorkerGlobalScope is not a Window. A resource hint is defined in HTML as a <link> element owned by a Document: the “process the linked resource” steps run against that document, the fetch they start joins that document’s fetch group, and the priority tier it receives is assigned by that document’s resource scheduler. There is no document in worker scope, therefore no head to append to, no preload scanner reading worker bytes, and no fetch group for a hint to join. The HTMLLinkElement constructor itself is not exposed — the capability check that guards runtime hint injection in a page, link.relList.supports(rel), cannot even be evaluated there.

What the worker does own is the interception point. It sits between a controlled client’s fetch group and the network stack, and it shares two substrates with every client it controls. The first is the HTTP cache: a service worker and its clients live on the same origin under the same top-level cache partition key, so a response the worker fetched and that carries a usable freshness lifetime is served to the document from cache without a second round trip. The second is the connection pool: sockets are keyed by origin plus partition, not by fetch group, so a TCP+TLS handshake the worker completes is a handshake the document does not repeat. Those two shared substrates are why a worker can produce the effect of preconnect and preload without ever creating a hint element.

The third lever is the response bytes themselves. Because the worker can rewrite the navigation response before the parser ever sees it, it can put real <link rel="preload"> markup into the <head> — and markup in the byte stream is exactly what the speculative preload scanner is built to read. This is the only channel that produces genuinely parser-discovered hints. Note what does not work portably: adding a Link: </font.woff2>; rel=preload header to a Response you construct in the worker. Header-driven hint processing is specified against the response the navigation actually received, and engines differ on whether a worker-synthesised header re-enters that path. Measure it before you depend on it; the markup route behaves identically everywhere.

Three channels a service worker can use to influence document-level resource hints, and the one direct call that is impossibleFour rows. The top row shows a blocked dashed arrow from the worker’s fetch handler to document.head, marked with a cross because worker scope has no Document. Below it, three working channels: rewriting the navigation response so the preload scanner discovers link tags, posting a message so the page appends the link itself, and fetching inside the worker so the shared HTTP cache and socket pool are warm for the later document fetch. ServiceWorkerGlobalScope Document (main thread) fetch event handler worker thread document.head main thread appendChild is not available worker scope has no Document 1 — rewrite the navigation response stream event.respondWith() + TransformStream link rel=preload inserted into the streamed head Preload scanner discovers at 0 ms 2 — message a controlled client client.postMessage() from the worker page listener appends the link to document.head Hint joins the document fetch group 3 — warm the shared pool self.fetch(url) inside the worker HTTP cache partition and socket pool are shared Later page fetch reuses a warm socket

Choosing a Channel

The three channels are not interchangeable, and picking the wrong one is why most service-worker hint code produces no measurable change. Stream rewriting is the only channel that beats the parser: the hint exists before the document does, so its fetch starts in the same millisecond the scanner meets it. It is also the only channel available on a cold navigation, when no client window exists yet. The postMessage bridge is for decisions made after a document is alive — a cache miss the worker just observed, a route the worker knows is stale — and it inherits the ordinary 10–50 ms runtime injection penalty because the hint arrives after the scanner has finished. Direct self.fetch() warming is for the cases where no hint element would help anyway: an origin you want connected before the app’s first XHR, or a response you want in the shared cache regardless of which document eventually asks for it.

Decision tree for choosing which service worker channel should deliver a resource hintA branching chart. If the worker is about to serve this navigation’s HTML, stitch the hints into the streamed head. Otherwise, if a controlled window is already loaded, postMessage the hint so the page appends it; if not, warm the shared pool with self.fetch. Only the stream channel is visible to the preload scanner. Worker wants a resource warm Is the worker about to serve this navigation’s HTML? yes no Stitch the hints into the streamed head Is a controlled window already loaded? yes no postMessage the hint; the page appends it Warm the shared pool with self.fetch() Only the stream channel is seen by the preload scanner.

The bridge is worth writing once and reusing, because it is the piece most teams get subtly wrong — they post the hint and then build the element in the worker, or they build it in the page without the capability check.

// --- worker scope: decide, then hand the decision to something that owns a head.
async function hintClients(hints) {
  const windows = await self.clients.matchAll({ type: 'window' });
  for (const client of windows) {
    // Ship the DECISION, never a DOM node: the worker knows the cache state,
    // the page owns the fetch group that a <link> must join to be scheduled.
    client.postMessage({ type: 'resource-hints', hints });
  }
}

// --- page scope: the only place a <link> can legally be created.
navigator.serviceWorker.addEventListener('message', (event) => {
  if (event.data?.type !== 'resource-hints') return;
  for (const hint of event.data.hints) {
    const link = document.createElement('link');
    // Unsupported rel values append silently and never fetch, so an unchecked
    // append masks the failure instead of surfacing it.
    if (!link.relList.supports(hint.rel)) continue;
    Object.assign(link, hint);
    document.head.append(link);
  }
});

Minimal Reproduction

The broken version is the one almost everyone writes first. It looks correct because worker code and page code share a language, and it fails silently because an exception thrown inside a fetch listener does not stop the navigation — the browser simply falls back to the network.

// BROKEN — throws "ReferenceError: document is not defined" on every navigation.
self.addEventListener('fetch', (event) => {
  const link = document.createElement('link'); // no Document in worker scope
  link.rel = 'preload';
  link.as = 'font';
  link.href = '/fonts/brand-var.woff2';
  document.head.appendChild(link);
});

The working version rewrites the bytes instead. Hold only enough of the stream to find the end of the <head> start tag, insert there, and let everything after it flow through untouched.

const HINTS = [
  // as + type + crossorigin must match the CSS-triggered font request exactly.
  // A mismatch on any one of the three opens a second cache slot and the font
  // is fetched twice, which costs more than the hint saves.
  '<link rel="preload" as="font" type="font/woff2" href="/fonts/brand-var.woff2" crossorigin>',
  // fetchpriority lifts the LCP candidate out of the default image tier before
  // layout has run, which is the only window in which the promotion is free.
  '<link rel="preload" as="image" href="/img/hero-1200.avif" fetchpriority="high">',
  // The socket opens while the HTML is still streaming, so the first XHR the
  // hydrated app fires finds a connected, TLS-negotiated origin.
  '<link rel="preconnect" href="https://api.example.com" crossorigin>',
].join('');

function headInjector(markup) {
  const decoder = new TextDecoder();
  const encoder = new TextEncoder();
  let injected = false;
  let carry = '';
  return new TransformStream({
    transform(chunk, controller) {
      if (injected) { controller.enqueue(chunk); return; }
      carry += decoder.decode(chunk, { stream: true });
      const headAt = carry.indexOf('<head');
      const closeAt = headAt === -1 ? -1 : carry.indexOf('>', headAt);
      // Hold the chunk rather than guessing: <head ...> can straddle a chunk
      // boundary, and a half-written tag would corrupt the parser's input.
      if (closeAt === -1) return;
      injected = true;
      // Inserting immediately after <head> puts the hints ahead of any
      // stylesheet, so the scanner queues them before the parser can block.
      controller.enqueue(encoder.encode(
        carry.slice(0, closeAt + 1) + markup + carry.slice(closeAt + 1)
      ));
      carry = '';
    },
    flush(controller) {
      if (carry) controller.enqueue(encoder.encode(carry));
    },
  });
}

self.addEventListener('fetch', (event) => {
  // Only a navigation carries a head to stitch; sub-resource fetches must pass
  // through untouched or every image response pays the decode cost above.
  if (event.request.mode !== 'navigate') return;
  event.respondWith((async () => {
    const response = (await event.preloadResponse) || (await fetch(event.request));
    const type = response.headers.get('content-type') || '';
    if (!type.includes('text/html') || !response.body) return response;
    return new Response(response.body.pipeThrough(headInjector(HINTS)), {
      status: response.status,
      statusText: response.statusText,
      headers: response.headers, // preserve caching and security headers verbatim
    });
  })());
});

Why a Cold Worker Boot Eats the Head Start

None of the above helps if the worker itself is the stall. A fetch event cannot fire until the worker’s script has been fetched (or read from script cache), evaluated, and its global installed. On a mid-tier Android device that boot costs 80–250 ms, and it lands directly in front of the navigation request — the browser has the URL and an idle socket, and is waiting on JavaScript before it will ask for the HTML. A worker that adds 180 ms of boot in order to save 120 ms of discovery is a net regression, which is why so many “the service worker made it slower” reports are real.

Navigation preload removes the serialisation. When enabled, the browser issues the navigation request at navigation start, concurrently with worker boot, and hands the in-flight response to the fetch event as event.preloadResponse. The boot cost is then overlapped rather than added, and the stitched hints ride a response that was already on the wire. The request carries Service-Worker-Navigation-Preload: true so the origin can distinguish it, and setHeaderValue() lets you version the shell the edge should return.

self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    if (self.registration.navigationPreload) {
      // Starts the navigation request concurrently with the 80-250 ms boot, so
      // the worker never sits in front of the critical HTML request.
      await self.registration.navigationPreload.enable();
      // The origin sees this value on Service-Worker-Navigation-Preload and can
      // return a shell whose hints match what this worker version will stitch.
      await self.registration.navigationPreload.setHeaderValue('shell-v4');
    }
    await self.clients.claim();
  })());
});

Timeline comparing a cold service worker boot against navigation preload with hints stitched into the streamTwo request timelines on a shared axis from zero to one thousand milliseconds. Before, a 180 millisecond worker boot precedes the navigation fetch, parsing starts at 520 milliseconds and LCP lands at 940. After, navigation preload runs concurrently with boot, the stitched HTML arrives at 360 milliseconds, the hero image and font start at 330 and LCP lands at 620. Before — cold worker boot, no navigation preload LCP 940 ms Service worker boot Navigation fetch Parse + scanner Hero image + web font After — navigation preload + hints stitched into the stream LCP 620 ms Worker boot (parallel) Navigation preload HTML + stitched hints Hero image + web font 0 200 400 600 800 1000 time from navigation start (ms)

Deterministic Fix Protocol

Work top to bottom. Each step is observable in the Network panel before you move to the next.

  • [ ] 1. Delete every DOM reference from worker scope. Search the worker bundle for document., window. and localStorage. Any hit is either dead code or an exception being swallowed by the fetch handler. Confirm the worker’s console is clean across a hard reload.
  • [ ] 2. Enable navigation preload in activate. Call registration.navigationPreload.enable() inside event.waitUntil(), then verify the navigation request in DevTools carries the Service-Worker-Navigation-Preload request header. Without this, every millisecond of boot is added to time to first byte.
  • [ ] 3. Scope the rewrite narrowly. Return early unless event.request.mode === 'navigate' and the response content type contains text/html. Rewriting a JSON or image body corrupts it, and passing every sub-resource through a TransformStream adds decode cost to requests that gain nothing.
  • [ ] 4. Stitch hints immediately after the <head> start tag. Insert before any stylesheet link so the scanner queues the font and hero image before the parser can be blocked. Verify the Initiator column now reads the document — not a script — for those two requests.
  • [ ] 5. Match as, type and crossorigin to the consuming request. A preloaded font whose hint omits crossorigin is fetched twice into two cache slots. Check the Size column: the second fetch shows a real transfer instead of (memory cache) when the attributes disagree.
  • [ ] 6. Move post-load decisions onto the postMessage bridge. Anything the worker learns after the document exists — a cache miss, a stale route payload — goes through clients.matchAll() and is appended by the page, with relList.supports() guarding the append.
  • [ ] 7. Budget the warm-up fetches. Cap self.fetch() warming at two origins and skip it entirely when navigator.connection.saveData is true. Each warmed origin holds a socket that competes with the critical path, the same budget that governs strategic preconnect from markup.
  • [ ] 8. Re-measure with the worker cold. Unregister, hard-reload, and compare. A warm-worker profile hides the boot cost that step 2 exists to remove, so a cold run is the only honest measurement.

Before/After Metrics

Measured on a Moto G-class profile (4× CPU slowdown, 20 Mbps down, 80 ms RTT) against a retail progressive web app whose worker previously served a cached shell and did nothing with hints. “After” applies steps 2–5; every run starts with the worker stopped.

Metric Cold worker, no hints Preload + stitched hints Change
Worker boot before fetch event 180 ms 180 ms (overlapped) serialised → parallel
Navigation response first byte 520 ms 300 ms −220 ms
Hero image request start 585 ms 330 ms −255 ms
Font request start 600 ms 335 ms −265 ms
API origin connected (TCP+TLS) 1120 ms 410 ms −710 ms
LCP (lab, cold worker) 940 ms 620 ms −34%
LCP (lab, warm worker) 610 ms 470 ms −23%

The API connection is the largest single win and the least visible one: nothing on the page waits for it during load, so it never appears in an LCP trace — it shows up as the first interaction after hydration feeling instant instead of costing a full handshake.

FAQ

Does a resource the service worker fetched itself get reused by the page?

Only if it is reusable from the shared HTTP cache. A self.fetch() in worker scope populates the same partitioned HTTP cache the document reads, so a response carrying a positive max-age is served to the page with no second network trip. Two things break that: a response marked no-store is never stored anywhere, and a response you wrote into Cache Storage with caches.put() is invisible to the document until the worker answers a later fetch event from it. That difference between the two caches is the same one that governs stale-while-revalidate versus worker-managed caching.

Can I set fetchpriority on a fetch made inside a service worker?

Chromium honours the priority member of the RequestInit dictionary — fetch(url, { priority: 'high' }) — and engines that do not implement it ignore the unknown member without throwing, so the call is safe everywhere. The caveat is scheduling scope rather than support: worker fetches belong to the worker’s own fetch group. A high-priority worker fetch therefore competes with the document’s queue instead of joining it, and on a saturated HTTP/1.1 origin it can push a genuinely critical document request back. Treat it as a way to order the worker’s own work, not as a substitute for a document-level hint.

It delays it by exactly as long as you buffer, and no longer. Holding bytes until the first > after <head> almost always resolves inside the first chunk, costing a fraction of a millisecond. The failure mode is buffering the whole document before rewriting: that converts a streamed response into a blocking one and reliably costs more than the hints recover. If your edge can emit the hints itself, prefer that — a 103 Early Hints interim response starts discovery before the origin has produced a single byte of HTML, which no worker-side rewrite can match.


Related