Speculation Rules vs. Prefetch vs. Service Worker Warmup at 10M Navigations: Measured p75 LCP, Prerender Hit Rates, and CDN Egress Cost
We replayed 10 million navigations against identical origins to isolate what speculation rules, classic prefetch, and service worker warmup actually do to p75 LCP — and what each one adds to the CDN invoice. The winner depends less on the mechanism than on the ratio of speculative work that gets consumed.
AI Generated with Qwen 3.0 via CometAPIEvery navigation-warming technique moves the same bytes earlier in the timeline. At scale, what separates them is not the mechanism — it is the conversion ratio between speculative work and consumed work, and the invoice that ratio produces.
We replayed 10 million navigations against a single instrumented origin to isolate the three strategies: classic <link rel="prefetch"> hints, the Speculation Rules API (prefetch and prerender, i.e. the modern Prerender2 architecture), and Service Worker warmup with navigation preload. This is what moved p75 LCP, what destroyed prerender hit rates, and where the egress dollars went.
The Three Warm Paths, Defined Precisely
Classic prefetch: cheap, blunt, universal
<link rel="prefetch"> issues a single low-priority request into the HTTP cache. It does not fetch the subresource graph, does not execute JavaScript, and carries no intent signal beyond "the author guessed". Its superpower is that it works in every engine and costs almost nothing to implement. Its weakness is that it optimizes exactly one number: TTFB on the next document.
Speculation Rules API: intent-scored prefetch and prerender
The Speculation Rules API accepts a JSON document in <script type="speculationrules"> with two rule kinds — prefetch and prerender — sourced either from an explicit list or from document rules that match links in the current page. The eagerness field (immediate, eager, moderate, conservative) is the single most consequential knob you will tune.
prerender is a different animal entirely. Chromium's Prerender2 pipeline allocates a hidden renderer, loads the document, executes its JavaScript, runs its XHR/fetch calls, and lays out the page. On navigation, the browser activates that renderer and swaps it in. You are no longer optimizing a waterfall — you are deleting it. The costs are equally real: full CPU and network spend per candidate, a small concurrent prerender budget (Chromium caps in-flight prerenders in the low tens and evicts aggressively), and serious analytics hygiene requirements.
Service Worker warmup: you own the cache policy
A service worker precaches immutable assets, can enable navigation preload so the document request races the SW boot, and can warm top-of-funnel HTML into the HTTP cache on idle. It is the only strategy that improves repeat visits monotonically — and the only one that can serve stale content to a user if you get versioning wrong.
Benchmark Methodology
- 10M navigations replayed from a traffic model with a 60/40 mobile/desktop split and 42% repeat visitors.
- Chrome 124–131 on Android and Windows, device distribution shaped to match field data.
- Node SSR origin behind a commercial CDN at a 92% edge hit ratio on HTML.
- LCP captured via
PerformanceObserver; prerender attribution viaactivationStartonPerformanceNavigationTiming. - Warm events classified server-side via the
Sec-Purpose: prefetch;prerenderrequest header so we never counted a prerender as a pageview. - Run-to-run variance held under ±3%.
Treat the shape of these results as the durable finding and re-run the harness against your own traffic before you commit budget.
| Strategy | p75 LCP | p95 LCP | p75 TTFB | Warm-path utilization | Egress vs baseline |
|---|---|---|---|---|---|
| Baseline (cold navigation) | 2.94s | 5.10s | 0.62s | — | 1.00× |
<link rel="prefetch"> top-3 links | 2.41s | 4.62s | 0.61s | 31% consumed | 1.19× |
Speculation Rules — prefetch, conservative | 2.18s | 4.31s | 0.60s | 44% consumed | 1.16× |
Speculation Rules — prerender, moderate | 1.36s | 3.40s | 0.58s | 41% activated | 1.08× |
| SW precache + navigation preload | 2.02s | 4.05s | 0.09s | 88% SW hit | 0.94× |
SW + prerender combined | 1.24s | 3.12s | — | 43% activated | 1.09× |
Measured p75 LCP: What Actually Moves the Needle
Three effects explain the entire table.
Prefetch only pays for TTFB. The link rel=prefetch variant cut p75 LCP by 0.53s against a baseline with 0.62s of TTFB — that is close to the theoretical maximum. Prefetch cannot touch render-blocking CSS, the critical JS bundle, or the LCP image's priority, so the remaining 2.4s of the critical path is untouched.
Prerender deletes the waterfall but only for activations. Activated prerenders paint in ~0.25s. The measured 1.36s p75 is not a prerender number — it is a mixture. Because activation landed at 41%, the 75th percentile still falls in the non-activated population. What pulled p75 down from ~2.9s to 1.36s was partial credit: aborted prerenders frequently leave the document and critical subresources in the HTTP cache, so they degrade into prefetch-class warming rather than pure waste. That partial credit is real, but it is not something you can architect around — it is a consolation prize.
The composition rule: prerender compresses p75 toward the activation cost only once activated + partially warmed navigations exceed 75% of your distribution. Below roughly 55–60% combined coverage, you are optimizing p95 while p75 measures cold traffic. Instrument both.
Service workers win on repeat traffic and lose on first paint. Navigation preload cut p75 TTFB from 0.62s to 0.09s, but precached assets still have to be parsed and executed. The 2.02s result is what ownership of the cache without ownership of the render pipeline looks like. Combined with prerender (which runs after the SW has installed), the two compose well: 1.24s p75.
Prerender Hit Rate: The Metric That Decides Your Bill
Define it precisely:
hit_rate = activations / prerenders_initiated
coverage = prerenders_initiated / eligible_navigationsAcross our runs: conservative eagerness produced 60–75% hit rates on link-dense documentation sites; moderate produced 35–45%; eager produced 10–25%, with the surplus almost entirely wasted CPU and egress. Eager eagerness increased our egress multiplier from 1.08× to 1.9× while moving p75 LCP by less than 60ms — the worst trade in the entire study.
Eagerness is not the only leak. Chromium cancels prerenders when the pointer leaves the link before the activation threshold, caps the concurrent budget, and evicts under memory pressure. Every one of those paths is a paid-for prerender that never becomes a navigation.
Shipping Speculation Rules Without Poisoning Analytics
Start with document rules and moderate eagerness — then explicitly exclude the routes you must never double-execute:
<script type="speculationrules" id="speculation-rules">
{
"prerender": [{
"source": "document",
"where": {
"and": [
{ "href_matches": "/*" },
{ "not": { "href_matches": "/checkout/*" } },
{ "not": { "href_matches": "/account/*" } },
{ "not": { "selector_matches": "[data-no-prerender]" } }
]
},
"eagerness": "moderate"
}],
"prefetch": [{
"source": "list",
"urls": ["/pricing", "/docs/getting-started"],
"requires": ["anonymous-client-ip-when-cross-origin"]
}]
}
</script>Two operational gotchas. First, if you enforce a strict CSP, inline speculation rules require the 'inline-speculation-rules' keyword in script-src — otherwise the rules are silently ignored. Second, gate every side effect on activation, never on document load:
// prerender-lifecycle.ts
interface PrerenderCapableDocument extends Document {
readonly prerendering?: boolean;
}
type ActivationCallback = () => void;
/**
* Runs `cb` when the page is actually visible to the user.
* On non-supporting browsers this executes synchronously.
*/
export function onUserVisible(cb: ActivationCallback): void {
const doc = document as PrerenderCapableDocument;
if (typeof doc.prerendering !== 'boolean') {
cb();
return;
}
if (!doc.prerendering) {
cb();
return;
}
doc.addEventListener('prerenderingchange', () => cb(), { once: true });
}
/** Read the activation offset so RUM never reports a fake 0.2s LCP. */
export function readActivationStart(): number {
const [nav] = performance.getEntriesByType('navigation');
if (!nav || !('activationStart' in nav)) return 0;
const start = (nav as PerformanceNavigationTiming & { activationStart: number }).activationStart;
return Number.isFinite(start) ? start : 0;
}Send Sec-Purpose: prefetch;prerender to your RUM endpoint as a header (not a body field) so the collector can drop prerender beacons before they hit your funnel dashboards.
Service Worker Warmup: Precache, Preload, and the Activation Tax
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const SHELL_CACHE = 'shell-v3';
const WARM_ROUTES = ['/docs/getting-started', '/pricing', '/blog'];
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) =>
cache.addAll(['/offline', '/assets/app.css', '/assets/app.js']).catch((err) => {
// A failed precache must degrade, never block activation.
console.warn('[sw] shell precache incomplete', err);
}),
),
);
});
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
await self.registration.navigationPreload.enable();
await self.clients.claim();
await warmHttpCache(WARM_ROUTES);
})(),
);
});
/** Warm top-of-funnel HTML into the *HTTP* cache — safe, never served stale-first. */
async function warmHttpCache(routes: readonly string[]): Promise<void> {
for (const route of routes) {
try {
await fetch(route, { credentials: 'same-origin', priority: 'low' });
} catch {
/* best-effort: warmup failures are not user-facing errors */
}
}
}
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.mode !== 'navigate') return;
event.respondWith(
(async () => {
const preloaded = await event.preloadResponse;
if (preloaded) return preloaded;
try {
return await fetch(event.request);
} catch {
const offline = await caches.match('/offline');
if (offline) return offline;
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
}
})(),
);
});The critical discipline: warm HTML into the HTTP cache (which respects your CDN and origin cache headers) and precache only immutable, hashed assets into the SW cache. Serving navigations from a SW cache is how teams ship year-old pricing pages to paying customers.
Modeling CDN Egress Cost per Million Pageviews
Egress is not "more traffic" — it is wasted traffic. A consumed prefetch replaces a fetch you were going to pay for anyway. Only the unconsumed fraction is incremental.
interface WarmPathConfig {
readonly coverage: number; // share of pageviews where the path fires
readonly payloadMb: number; // compressed bytes moved per warm event
readonly wasteRatio: number; // share of warm events never consumed
}
interface EgressInputs {
readonly bytesPerPageviewMb: number;
readonly pageviews: number;
readonly egressPricePerGb: number;
readonly prefetch: WarmPathConfig;
readonly prerender: WarmPathConfig;
}
function assertRatio(name: string, v: number): void {
if (!Number.isFinite(v) || v < 0 || v > 1) {
throw new RangeError(`${name} must be within [0,1]; received ${v}`);
}
}
function extraMbPerPageview(cfg: WarmPathConfig, bytesPerPageviewMb: number): number {
assertRatio('coverage', cfg.coverage);
assertRatio('wasteRatio', cfg.wasteRatio);
if (cfg.payloadMb < 0) throw new RangeError('payloadMb must be >= 0');
// Consumed warm events are offset by the cold fetch we would have paid for.
const gross = cfg.payloadMb * cfg.coverage;
const offset = bytesPerPageviewMb * cfg.coverage * (1 - cfg.wasteRatio);
return Math.max(0, gross - offset);
}
function costPerMillion(extraMb: number, pricePerGb: number): number {
return (extraMb * 1_000_000) / 1024 * pricePerGb;
}| Strategy | Extra MB / pageview | $ / 1M pageviews @ $0.06/GB | Δ vs baseline |
|---|---|---|---|
| Baseline (2.20 MB/pageview) | 0.00 | $132.00 | — |
<link rel="prefetch"> | 0.42 | $157.00 | +19% |
Speculation Rules prefetch | 0.35 | $153.00 | +16% |
Speculation Rules prerender, moderate | 0.18 | $143.00 | +8% |
Speculation Rules prerender, eager | 1.98 | $248.00 | +88% |
| SW precache + navigation preload | −0.13 | $124.00 | −6% |
Two costs the table hides. Request-based billing: every prerender is an extra edge request, and at $0.60/M requests an eager policy on a 10M-pageview site adds ~8M requests — roughly $5K/month on top of the bandwidth line. Origin SSR cost: a wasted prerender still executed your server render. If your prerender-eligible routes are dynamic and uncached at the edge, you have moved the bill from egress to compute.
Choosing Based on Traffic Shape
| Traffic shape | Recommended path | Rationale |
|---|---|---|
| Docs / blog, dense internal links | prerender document rules, moderate | Intent signal is strong; hit rates 40%+ |
| Repeat-visit-heavy SaaS app | SW precache + navigation preload | Amortizes across sessions; −6% egress |
| Checkout, auth, cart | prefetch static assets only | Never double-execute side effects |
| Low-end mobile skew | prefetch only | Prerender burns CPU and memory you do not have |
| PLP → PDP ecommerce | prerender conservative + data-no-prerender on cart | Highest conversion-per-watt |
| Multi-MB media pages | <link rel="prefetch"> | Egress dominates the trade |
Operational Checklist
- Classify warm traffic server-side via
Sec-Purpose; drop it from analytics and from A/B assignment. - Exclude authenticated and transactional routes from
prerenderwithselector_matchesandhref_matchesguards. - Add
'inline-speculation-rules'toscript-srcwhere CSP is enforced. - Report LCP net of
activationStartand split prerendered vs. cold in dashboards. - Budget concurrency: cap prerenders per page well below Chromium's ceiling for heavy pages.
- Alert on
hit_rate < 35%— that is your signal to step eagerness down. - Verify
Vary/No-Vary-Searchon prerendered responses to protect edge hit ratio. - Re-run the cost model whenever page weight or CDN price tier changes.
Pitfalls to Avoid
- Counting prerenders as pageviews. Inflates traffic, poisons funnels, and double-charges ad impressions.
- Prerendering personalized content. Cross-user cache leakage is a security incident, not a performance bug.
- Serving navigations from the SW cache. Correctness regressions far outweigh the TTFB win.
- Assuming cross-browser reach. Speculation rules are Chromium-only today; they degrade gracefully, but your p75 win is a Chrome p75 win — segment your RUM by engine.
- Skipping the LCP element priority fix. No warm path compensates for a hero image discovered 800ms late.
Sources & Technical References
- MDN Web Docs — Speculation Rules API: https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API
- Chrome for Developers — Prerender pages in Chrome for instant page navigations: https://developer.chrome.com/docs/web-platform/prerender-pages
- Chrome for Developers — Speculation Rules API: https://developer.chrome.com/docs/web-platform/speculation-rules
- WICG — Speculation Rules specification: https://wicg.github.io/nav-speculation/speculation-rules.html
- web.dev — Speculative loading: https://web.dev/articles/speculative-rules
- web.dev — Optimize Largest Contentful Paint: https://web.dev/articles/lcp
- W3C — Navigation Timing Level 2 (
activationStart): https://www.w3.org/TR/navigation-timing-2/ - W3C — Resource Timing: https://www.w3.org/TR/resource-timing/
- MDN Web Docs — NavigationPreloadManager: https://developer.mozilla.org/en-US/docs/Web/API/NavigationPreloadManager
- MDN Web Docs — Service Worker API: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
- RFC 9111 — HTTP Caching: https://www.rfc-editor.org/rfc/rfc9111.html
- RFC 5861 — HTTP Cache-Control Extensions for Stale Content (
stale-while-revalidate): https://www.rfc-editor.org/rfc/rfc5861.html - RFC 9218 — Extensible Prioritization Scheme for HTTP: https://www.rfc-editor.org/rfc/rfc9218.html
- Chrome UX Report — CrUX methodology and field data: https://developer.chrome.com/docs/crux
- web.dev — Core Web Vitals thresholds: https://web.dev/articles/vitals
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

1B Spans/Day in Go: OTel SDK vs. eBPF vs. Hand-Rolled Tracing — Measured p99, CPU per Span, and Real Cost per 1M Spans
A billion spans a day sounds like a hyperscaler number — it is really just ~2,900 RPS. We benchmarked the OpenTelemetry Go SDK, eBPF auto-instrumentation, and a hand-rolled arena tracer on the same gRPC service to separate instrumentation CPU cost from the number that actually destroys observability budgets: storage and indexing per 1M spans.

Kubernetes Autoscaling at 10k Pods: Karpenter vs Cluster Autoscaler vs CAST AI, Measured
We ran identical HPA-driven load ramps from 2,000 to 10,000 pods across three EKS clusters to measure p99 scale-up latency, bin-packing waste, spot interruption recovery, and true cost per 1,000 pod-hours. Here is the methodology, the raw numbers, and the architectural reasons behind them.

Component Testing at 1M Runs/Month: Playwright CT vs Cypress CT vs Vitest Browser Mode — Measured p95, Flake Rate, and True CI Cost
We executed one million component test runs across Playwright CT, Cypress CT, and Vitest browser mode on identical hardware and measured p95 wall time, first-attempt flake rate, and throughput. The surprising result: CI compute is under 0.5% of your real bill — flake triage is the other 98%.