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.
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.
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();
})());
});
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.andlocalStorage. Any hit is either dead code or an exception being swallowed by thefetchhandler. Confirm the worker’s console is clean across a hard reload. - [ ] 2. Enable navigation preload in
activate. Callregistration.navigationPreload.enable()insideevent.waitUntil(), then verify the navigation request in DevTools carries theService-Worker-Navigation-Preloadrequest 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 containstext/html. Rewriting a JSON or image body corrupts it, and passing every sub-resource through aTransformStreamadds 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,typeandcrossoriginto the consuming request. A preloaded font whose hint omitscrossoriginis 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
postMessagebridge. Anything the worker learns after the document exists — a cache miss, a stale route payload — goes throughclients.matchAll()and is appended by the page, withrelList.supports()guarding the append. - [ ] 7. Budget the warm-up fetches. Cap
self.fetch()warming at two origins and skip it entirely whennavigator.connection.saveDatais 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.
Does stitching link tags into the HTML stream delay time to first byte?
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
- Dynamic Hint Injection via JavaScript — parent topic: injection timing, concurrency guards and framework lifecycle patterns
- Fixing Preload Scanner Misses in Single-Page Apps — the same discovery problem seen from the document side
- Stale-While-Revalidate vs Service Worker Cache — which cache actually serves the resource you warmed
- Resource Hint Implementation & Preloading Strategies — up to the section root