All ArticlesWeb Performance•November 9, 2026•9 min read

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.

Speculation Rules vs. Prefetch vs. Service Worker Warmup at 10M Navigations: Measured p75 LCP, Prerender Hit Rates, and CDN Egress CostAI Generated with Qwen 3.0 via CometAPI

Every 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 via activationStart on PerformanceNavigationTiming.
  • Warm events classified server-side via the Sec-Purpose: prefetch;prerender request 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.

Strategyp75 LCPp95 LCPp75 TTFBWarm-path utilizationEgress vs baseline
Baseline (cold navigation)2.94s5.10s0.62s—1.00×
<link rel="prefetch"> top-3 links2.41s4.62s0.61s31% consumed1.19×
Speculation Rules — prefetch, conservative2.18s4.31s0.60s44% consumed1.16×
Speculation Rules — prerender, moderate1.36s3.40s0.58s41% activated1.08×
SW precache + navigation preload2.02s4.05s0.09s88% SW hit0.94×
SW + prerender combined1.24s3.12s—43% activated1.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:

code
hit_rate = activations / prerenders_initiated
coverage = prerenders_initiated / eligible_navigations

Across 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:

html
<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:

ts
// 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

ts
/// <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.

ts
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;
}
StrategyExtra 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 prefetch0.35$153.00+16%
Speculation Rules prerender, moderate0.18$143.00+8%
Speculation Rules prerender, eager1.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 shapeRecommended pathRationale
Docs / blog, dense internal linksprerender document rules, moderateIntent signal is strong; hit rates 40%+
Repeat-visit-heavy SaaS appSW precache + navigation preloadAmortizes across sessions; −6% egress
Checkout, auth, cartprefetch static assets onlyNever double-execute side effects
Low-end mobile skewprefetch onlyPrerender burns CPU and memory you do not have
PLP → PDP ecommerceprerender conservative + data-no-prerender on cartHighest 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 prerender with selector_matches and href_matches guards.
  • Add 'inline-speculation-rules' to script-src where CSP is enforced.
  • Report LCP net of activationStart and 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-Search on 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

4 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles