Speculation Rules Prerender and the Media Prefetch Budget

A prerender rule does not fetch a page — it fetches a page and every byte of media that page’s eventual viewport resolves to, once per candidate URL, whether or not the click ever arrives. Chromium’s only ceiling on this is a count: two concurrent prerenders at moderate eagerness, ten at eager. Nothing in the platform caps the bytes those prerenders transfer, so on a catalogue route carrying a 180 KB AVIF hero plus eight 24 KB thumbnails and a 48 KB poster frame, two candidates put 840 KB of speculative media on the wire before the user has committed to anything. This page defines a byte budget for that speculation and enforces it with admission control, assuming the rule syntax and lifecycle already covered in Early Hints and Speculation Rules for Media.

Prerequisites

Getting the third item right is most of the work. The number you need is not the size of the files on disk, and it is not the total weight of the page in a Lighthouse report either — it is the sum of the selected candidate from each srcset at the viewport widths your traffic actually uses, plus poster frames, plus any font or sprite the media components pull in. The practical way to produce it is to render each route once per breakpoint in a headless browser during the build, sum transferSize for every request whose destination is image or video, and write the maximum across breakpoints into a manifest keyed by href pattern. If your images are already generated by a sharp and ffmpeg build pipeline, the derivative sizes are known at that point and the manifest costs one extra pass rather than a whole new instrumentation step.

The arithmetic that sets the budget

Every speculation has a hit rate. Call the media weight a prerender of route r transfers W(r), and the probability that a speculated candidate is actually activated h. Because the media is going to be fetched anyway on a real click, the interesting number is not W, it is the waste: bytes transferred for candidates that were never activated, amortised over the ones that were.

waste per activated navigation = W × (1/h − 1)

With W = 420 KB for the catalogue route and hover-to-click conversion measured at 58% for moderate eagerness, that is 420 × 0.724 = 304 KB of pure waste for every navigation you accelerate. Move to eager, where the trigger fires on the merest pointer intent and conversion drops to about 22%, and the same route costs 1489 KB per activated navigation — a 4.9× multiplier on your entire media budget for a saving of roughly 300 ms. Drop to conservative, which fires on pointerdown and converts at over 90%, and the waste collapses to 37 KB but the prerender has only ~80 ms to complete before the navigation commits, so a 420 KB payload will not finish and you get a partially prerendered page.

The second lever is what the prerender actually downloads. If the origin serves a media-deferred variant to speculative navigations — document, critical CSS and the JS bundle, but placeholders instead of <img src> — then W for budgeting purposes drops to the 92 KB document payload, and the media is fetched at activation instead. The chart below plots both levers against each other using the measured conversion rates above.

Wasted Speculative Bytes by Eagerness and Deferral Strategy Grouped horizontal bar chart. At eager eagerness a full-media prerender wastes 1489 kilobytes per activated navigation versus 326 kilobytes when media is deferred. At moderate eagerness the figures are 304 and 67 kilobytes. At conservative eagerness they are 37 and 8 kilobytes. Wasted speculative KB per activated navigation — 420 KB route, 92 KB document eager · h=22% 1489 KB 326 KB moderate · h=58% 304 KB 67 KB conservative · h=91% 37 KB 8 KB prerender pulls the full media set media deferred to activation Waste = W × (1/h − 1). Conversion rates h are from 30 days of pointer telemetry on a catalogue route. Deferral changes W from 420 KB to the 92 KB document payload; it does not change h.
Eagerness Trigger Concurrent prerenders Measured h Waste at W=420 KB
immediate rule set parsed 10 11% 3398 KB
eager earliest pointer intent 10 22% 1489 KB
moderate ~200 ms hover 2 58% 304 KB
conservative pointerdown 2 91% 37 KB

The shape of that chart is the whole design brief. Eagerness is a coarse, high-variance lever that trades a large byte multiplier for a modest latency win, while deferral is a cheap structural change that cuts the multiplier by 4.5× at every eagerness level. Pick moderate, defer the media, and the budget question becomes tractable: at 67 KB of waste per activated navigation you can afford several candidate routes inside 1500 KB, and the remaining job is deciding which routes get rules.

The solution: budget-aware rule injection

Rather than hard-coding a rule set into the template, compute it at runtime from a build-time weight manifest and inject only what fits. The rule set is inert until it is in the DOM, and removing the node cancels any speculation it started, so a script is a legitimate — and the only precise — way to hold a byte ceiling.

// speculation-budget.js — admits routes into a prerender rule set only while
// they fit inside a fixed speculative-media budget for this page view.
//
// route-weights.json is emitted by the image build (sharp/ffmpeg) and maps an
// href pattern to the media bytes a prerender of that route would transfer at
// the widest breakpoint the current viewport can select. Keys are ordered by
// measured click probability, highest first.
import weights from '/build/route-weights.json' with { type: 'json' };
// e.g. { "/product/*": 420, "/catalogue/page-2": 260, "/compare": 180, "/lookbook/spring": 720 }

const BUDGET_KB = 1500;               // hard ceiling on speculative media per page view
const DEFERRED_DOC_KB = 92;           // what a media-deferred prerender actually pulls

// --- 1. Refuse to speculate at all on constrained clients -------------------
const c = navigator.connection ?? {};
const constrained =
  c.saveData === true ||              // user asked for less data; honour it, no exceptions
  /(^|-)2g$/.test(c.effectiveType) || // 2g and slow-2g: prerender will not finish anyway
  (navigator.deviceMemory ?? 8) < 4;  // Chromium also self-disables on low-memory Android
if (constrained) throw new Error('speculation withheld');

// --- 2. Pick eagerness from connection quality ------------------------------
// moderate = ~200 ms hover, capped at 2 concurrent prerenders, FIFO eviction.
// conservative = pointerdown; safer on 3g but rarely completes a media fetch.
const eagerness = c.effectiveType === '4g' ? 'moderate' : 'conservative';

// --- 3. Admit routes until the budget is exhausted --------------------------
// Deferred routes are charged DEFERRED_DOC_KB because the origin serves them a
// placeholder document (see the Sec-Purpose split below); everything else is
// charged its full media weight.
const deferrable = new Set(['/product/*', '/catalogue/page-2']);
let spent = 0;
const admitted = [];
for (const [pattern, weightKb] of Object.entries(weights)) {
  const cost = deferrable.has(pattern) ? DEFERRED_DOC_KB : weightKb;
  if (spent + cost > BUDGET_KB) continue;   // skip, do not break: a later route may still fit
  spent += cost;
  admitted.push(pattern);
}

// --- 4. Build the rule set --------------------------------------------------
const rules = {
  prerender: [{
    source: 'document',               // match links live in the DOM, not a frozen URL list
    where: {
      and: [
        { href_matches: admitted },   // array = OR across the admitted patterns
        // Never prerender a state-mutating or auth-gated URL: the request runs
        // with the user's credentials and the response is committed on click.
        { not: { href_matches: '/*\\?*add-to-cart*' } },
        { not: { selector_matches: '[rel~="nofollow"], [data-no-prerender]' } }
      ]
    },
    eagerness,                        // document rules default to 'conservative' if omitted
    // Treat these params as not varying the response so one prerender can serve
    // /product/x, /product/x?utm_source=a and /product/x?utm_medium=b. Without
    // this each tagged variant burns a separate slot AND a separate 420 KB.
    expects_no_vary_search: 'params=("utm_source" "utm_medium" "gclid")',
    referrer_policy: 'strict-origin-when-cross-origin',
    tag: 'budgeted-prerender'         // surfaces in the Sec-Speculation-Tags request header
  }],
  prefetch: [{
    source: 'document',
    // Everything that did NOT fit the prerender budget still gets a document-only
    // prefetch: ~15 KB, no media, no script execution, no byte risk.
    where: { and: [
      { href_matches: Object.keys(weights) },
      { not: { href_matches: admitted } }
    ]},
    eagerness: 'moderate',
    tag: 'budget-overflow-prefetch'
  }]
};

// --- 5. Inject, and register the cancel path --------------------------------
const node = document.createElement('script');
node.type = 'speculationrules';
node.textContent = JSON.stringify(rules);
document.head.append(node);

// Removing the node withdraws every pending speculation it started. Do this the
// moment the budget assumption stops holding — e.g. the user opens a lightbox
// that will itself pull full-resolution media.
addEventListener('mediaheavy:open', () => node.remove(), { once: true });

The admission loop is a first-fit bin pack, not a greedy prefix: it uses continue rather than break so a cheap route later in the manifest can still claim the tail of the budget after an expensive one is skipped. Ordering the manifest by click probability rather than by weight is what makes that behave sensibly — you want the most likely destination admitted first even if it is the heaviest.

Admission Control Against a 1500 KB Speculative Budget A horizontal budget track of 1500 kilobytes. Three routes are packed into it: the product route at 420 kilobytes, catalogue page two at 260 kilobytes and the compare route at 180 kilobytes, totalling 860 kilobytes and leaving 640 kilobytes free. A fourth route, the spring lookbook at 720 kilobytes, is rejected because admitting it would exceed the ceiling. Speculative media budget — 1500 KB per page view /product/* /catalogue/page-2 /compare 420 KB 260 KB 180 KB 640 KB free 0 750 KB 1500 KB Admitted: 860 KB across 3 routes — 2 will actually run, the rest queue behind the concurrency cap. /lookbook/spring — 720 KB rejected: 860 + 720 > 1500 → demoted to document-only prefetch First-fit, not greedy-prefix: skipping /lookbook/spring leaves room for any cheaper route later in the manifest. Manifest order is click probability, so the most likely destination is admitted even when it is the heaviest.

Verification

1. Check the admitted set against what Chromium actually ran

Open DevTools → Application → Speculative loads. The Rules tab shows each injected rule set with its tag; the Speculations tab lists every candidate URL with a status and, for anything that did not run, a failure reason string. What you are confirming is that the number of Running or Ready prerenders equals min(admitted.length, 2) and that nothing shows a status you did not plan for.

Candidates die in more ways than the concurrency cap, and the panel is the only place the reason is written down:

Prerender Candidate State Machine and Failure Reasons A state machine. A candidate matched by a rule moves to queued, then to prerendering, then to activated on navigation. From queued it can fall to a not-started state with reasons such as the running-prerenders limit, preloading disabled, data saver or battery saver. From prerendering it can fall to a discarded state with reasons such as memory limit exceeded, trigger destroyed, a disallowed API call, or a cross-origin navigation. Where a speculated media route actually ends up Rule matched Queued Prerendering Activated eagerness trigger fires slot free and budget holds user clicks the link capacity or policy runtime destroy Never started Discarded Reasons the panel prints for “never started” MaxNumOfRunningPrerendersExceeded PreloadingDisabled — user turned off page preload DataSaverEnabled / BatterySaverEnabled LowEndDevice — memory-constrained Android Reasons the panel prints for “discarded” MemoryLimitExceeded — heavy decoded bitmaps TriggerDestroyed — the rule node was removed MojoBinderPolicy — a blocked API was called Download / cross-origin redirect on the URL MemoryLimitExceeded is the media-specific one: decoded pixel buffers, not transferred bytes, are what blow the cap.

MemoryLimitExceeded deserves special attention on image-heavy routes, because it is charged against decoded pixels rather than transferred bytes. A 180 KB AVIF at 1600×900 occupies roughly 5.8 MB once decoded to RGBA, so a route with four such images holds 23 MB of bitmap in a renderer the user may never see. Transferred-byte budgets do not predict this at all — if the panel reports the reason, cut image dimensions or the number of eagerly decoded images rather than the file sizes.

2. Measure what the prerender actually transferred

Chrome lets you inspect the hidden renderer directly. In the Network panel, use the context dropdown at the left of the filter bar to switch from the top frame to the prerendering target, then read the transferred total in the status bar. That figure is the one your budget is about — compare it to spent from the injection script:

# Confirm the origin really serves a lighter document to speculative navigations.
# The two responses should differ by roughly the media weight of the route.
for P in "" "prefetch;prerender"; do
  printf '%-18s ' "${P:-normal}"
  curl -sS -o /dev/null -w '%{size_download} bytes  %{content_type}\n' \
    ${P:+-H "Sec-Purpose: $P"} \
    -H 'Accept: text/html' \
    https://example.com/product/atlas-jacket/
done
# Expect the prerender row to be materially smaller and to contain no <img src>.

# Count the image elements each variant would cause the renderer to fetch.
curl -sS -H 'Sec-Purpose: prefetch;prerender' https://example.com/product/atlas-jacket/ \
  | grep -oE '<img[^>]*src="[^"]+"' | wc -l
# Expect 0 for a correctly deferred variant; every hit here is unbudgeted media.

3. Attribute speculative traffic in the logs

Add Sec-Purpose to your access log format and compute the real hit rate rather than trusting the 58% used above — it is route-specific and moves with layout changes. The ratio you want is activated prerenders over issued prerenders, which you get by joining speculative navigations against the subsequent non-speculative request for the same URL from the same client within the session window. A prerendered page that is activated produces exactly one speculative navigation and then no navigation request at all, because the document is served from the hidden renderer; a prerender that is wasted produces a speculative navigation and nothing else. The distinguishing signal is therefore the next request from that client — if it is a subresource fetch for the speculated route’s media, the prerender converted.

Feed the resulting number back into the waste formula. If it has dropped below about 35%, downgrade that route’s eagerness one step and re-measure before touching anything else; eagerness is the only lever whose effect on h is large and immediate. Pair the check with a static ceiling in CI so a designer adding a fifth hero variant cannot silently double WLighthouse CI budget enforcement for image weight is the right place to put that assertion, because it already knows each route’s image weight and failing the build is far cheaper than discovering the regression in a bandwidth bill.

Common mistakes

1. Treating the concurrency cap as a byte cap

“Only two prerenders at moderate” is reassuring until you notice nothing bounds what each one weighs. Two candidates on a photo-led route transfer more than ten candidates on a text route. The cap is a limit on renderer processes, not on bandwidth.

Fix: budget in bytes and treat the concurrency limit as an unrelated constraint that happens to sit downstream. The admission loop above charges bytes; the browser then applies its own count limit to whatever survived.

2. Leaving eagerness off a document rule and assuming it defaults to something safe

Document rules default to conservative, list rules default to immediate. Teams that prototype with a list source and then switch to document routinely see their speculation quietly stop working, “fix” it by copying the immediate value across, and ship a rule set that prerenders every matching link on parse.

// WRONG — fires on parse, for every matching link in the document, at 420 KB each.
{ "prerender": [{ "source": "document", "where": { "href_matches": "/product/*" },
                  "eagerness": "immediate" }] }

// CORRECT — hover-gated, capped at 2 concurrent, ~58% conversion.
{ "prerender": [{ "source": "document", "where": { "href_matches": "/product/*" },
                  "eagerness": "moderate" }] }

3. Serving the identical heavy document to prerender navigations

The Sec-Purpose: prefetch;prerender header arrives on the navigation request only. Subresource fetches issued from inside the hidden renderer carry no such marker, so no CDN rule and no edge worker can distinguish speculative image traffic from real image traffic after the fact. The only point of control is the document itself.

<!-- WRONG: the prerenderer resolves srcset immediately and pulls the full set. -->
<img src="/media/atlas-1600.avif" srcset="/media/atlas-800.avif 800w,
     /media/atlas-1600.avif 1600w" sizes="100vw" width="1600" height="900" alt="Atlas jacket">

<!-- CORRECT: origin emits this variant only when Sec-Purpose contains "prerender".
     The renderer has nothing to fetch; activation swaps the attributes in. -->
<img data-src="/media/atlas-1600.avif" data-srcset="/media/atlas-800.avif 800w,
     /media/atlas-1600.avif 1600w" sizes="100vw" width="1600" height="900" alt="Atlas jacket">
Split-Serving a Media-Deferred Document to Prerenders A three-lane sequence diagram between the hidden prerendering renderer, the CDN edge and the origin. The navigation request carries Sec-Purpose prefetch and prerender, the edge varies on that header, and the origin returns a placeholder document. The renderer transfers only 92 kilobytes because subresource requests carry no Sec-Purpose marker and there is nothing to fetch. On activation, prerenderingchange fires and the 420 kilobyte media set is requested for the first time. Hidden renderer CDN edge Origin GET /product/atlas — Sec-Purpose: prefetch;prerender forwarded; Vary: Sec-Purpose splits the cache 200 — placeholder document, data-src only, 92 KB parses, runs JS, fetches CSS + bundle subresources carry NO Sec-Purpose — the edge cannot tell them apart user clicks → activation → prerenderingchange fires GET atlas-1600.avif — 420 KB, first time it is ever requested Speculative cost: 92 KB. Activated cost: 512 KB — identical to a cold navigation, paid only on a real click.

Warning: if you split-serve on Sec-Purpose, you must add Vary: Sec-Purpose to the response or a shared cache will hand the placeholder document to a real navigation. That page will render with no images at all until the activation handler runs — which it never will, because document.prerendering was false.

4. Ignoring query-string fan-out

Every distinct URL is a distinct speculation. A product grid whose links carry ?utm_source= variants will happily consume both moderate slots on two spellings of the same page and transfer the media twice.

Fix: expects_no_vary_search in the rule, matched by a No-Vary-Search response header on the target. Both halves are required — the rule alone tells the browser what to hope for; the header is what makes the response reusable.

5. Cancelling by disabling instead of removing

Setting type="text/plain" on the script tag, or flipping a flag your own code reads, does nothing: the rule set was consumed at insertion. Only removing the node — or removing the matching links from the document — withdraws pending speculations, which is why the script above holds a reference to node and calls node.remove().

Tradeoff: removing the rule set destroys prerenders that were nearly complete, so a budget that oscillates costs more than one that is slightly too conservative. Compute the budget once per page view and leave it alone; do not recompute on scroll or on every viewport change. The same restraint applies to the in-document hints described in preload vs prefetch for video and image assets — speculative bytes are only cheap when you spend them once.

What a shipped budget looks like

The end state on the catalogue route described here is a moderate prerender rule covering three admitted patterns, a media-deferred document variant behind Vary: Sec-Purpose, and a document-only prefetch rule catching everything that did not fit. Speculative transfer runs at 92 KB per candidate instead of 420 KB, two candidates run concurrently, and the measured waste settles around 67 KB per activated navigation — about 4% of the 1500 KB ceiling, against a perceived-latency win of a full page load.

The number worth reviewing quarterly is not the budget, it is W. Media weight only ever moves in one direction as a site matures, and a route that cost 420 KB when the rules were written will quietly cost 700 KB two redesigns later while the rule set carries on admitting it. Regenerate the manifest on every build, assert the ceiling in CI, and let the admission loop reject routes on its own rather than editing rule sets by hand — the arithmetic does not need supervision once the inputs are honest.