Tuning Speculation Rules Eagerness Settings
Symptom: your speculation rules parse without error and DevTools shows candidates reaching Ready, yet field navigations are no faster and origin request volume has multiplied — because the eagerness level you chose either fires too late for the speculation to finish before the click, or fires so early and so broadly that the candidate pool evicts the one page the user actually opens.
Root Cause: Eagerness Is a Lead-Time Budget, Not a Volume Dial
eagerness reads like an intensity setting, and that is the wrong model. In Chromium it selects exactly two things: which input event opens a candidate, and which candidate pool that rule draws from. It changes nothing about how the speculation is fetched — every speculative request still runs at the bottom of the fetch priority ladder and still carries Sec-Purpose. What it changes is when the clock starts, and the whole tuning problem is arithmetic on that clock.
The four triggers are precise, not fuzzy. immediate opens the candidate as soon as the rule set is processed, with no user signal at all. eager opens it the instant the pointer moves onto the link’s hit region — no dwell requirement, so a pointer merely travelling across a grid trips every card it passes. moderate requires roughly 200 ms of hover dwell, or a pointerdown, whichever comes first. conservative requires pointerdown or touchstart and nothing else. On mouse input the median gap between pointerover and click on a link a user has decided to open is around 620 ms; subtract the 200 ms dwell and moderate yields roughly 420 ms; pointerdown to click is around 95 ms. Those three numbers are your entire budget.
Against that budget sits the work the action must complete. A prefetch needs origin TTFB plus the document transfer — call it 320 ms on a typical dynamic origin over 4G. A prerender needs all of that plus subresource fetching, script execution and layout: the destination’s cold-cache time to Largest Contentful Paint, commonly 900–1,400 ms. Neither number depends on the initiating page, and neither is affected by eagerness. So the rule is mechanical: if the median lead time your trigger provides is smaller than the work the action requires, the candidate is still in flight when the user clicks. That is not a failure — the navigation adopts the in-flight speculation rather than racing it, so you bank the head start you did get — but it is not the instant navigation you were promised, and it is why “we enabled prerender and nothing changed” is such a common report.
The second half of the mechanism is the candidate pool. Chromium keeps two pools per action, keyed by eagerness class: immediate and eager share one, moderate and conservative share the other. Prefetch and prerender are pooled separately. Class A allows 50 live prefetches and 10 live prerenders; class B allows 2 of each, evicted first-in-first-out. Every rule in the document draws from the pool matching its own class, so eagerness is also, silently, a concurrency setting — and FIFO eviction means the oldest candidate dies when a new one arrives, which on a link-dense page is very often the one the user is circling back to.
| Level | Trigger in Chromium | Median lead before the click | Pool | Prefetch cap | Prerender cap |
|---|---|---|---|---|---|
immediate |
rule set processed | seconds (whole page dwell) | A | 50 | 10 |
eager |
pointer enters the link, no dwell | ~620 ms | A | 50 | 10 |
moderate |
~200 ms hover dwell, or pointerdown |
~420 ms | B | 2, FIFO | 2, FIFO |
conservative |
pointerdown / touchstart only |
~95 ms mouse, ~150 ms touch | B | 2, FIFO | 2, FIFO |
Note the default: a rule with urls and no eagerness is immediate; a rule with where and no eagerness is conservative. A document rule that “does nothing” in testing is very often a rule you never gave a level, quietly waiting for pointerdown.
Minimal Reproduction
Twelve product cards and one rule. Both variants below are wrong, and they are wrong in opposite directions — which is exactly the trap, because the natural response to each failure is to swap to the other.
<!doctype html>
<meta charset="utf-8">
<title>Eagerness thrash: 12 cards, one unscoped prerender rule</title>
<!-- BROKEN A — "eager" puts this rule in pool A (10 live prerenders) and fires
on pointer entry with no dwell. A pointer travelling to card 9 crosses
cards 1-8 on the way, so the scheduler opens eight hidden renderers that
the user never asked for. They are low priority on the wire, but they are
NOT free at the origin: eight full page loads hit the server inside one
second, and they share the same connection as the candidate that matters. -->
<script type="speculationrules">
{
"prerender": [
{ "where": { "href_matches": "/p/*" }, "eagerness": "eager" }
]
}
</script>
<!-- BROKEN B — the usual "fix". "moderate" moves the rule to pool B, capped at
two live prerenders with FIFO eviction. The origin load collapses, but now
the third link the user dwells on destroys the first one's renderer mid-load.
Coming back to an earlier card restarts it from zero, so the click arrives
on a renderer that is 180 ms into a 1,150 ms load. -->
<script type="speculationrules">
{
"prerender": [
{ "where": { "href_matches": "/p/*" }, "eagerness": "moderate" }
]
}
</script>
<main class="grid">
<a href="/p/1">Card 1</a><a href="/p/2">Card 2</a><a href="/p/3">Card 3</a>
<a href="/p/4">Card 4</a><a href="/p/5">Card 5</a><a href="/p/6">Card 6</a>
</main>
Variant B is the more instructive failure, because DevTools reports it as success right up until the click: each candidate does reach Ready or at least Running, and the failure is the transition you never see unless you watch the pool.
You cannot see this in the lab by clicking one link, so instrument the destination. Two navigation-timing fields separate “the speculation worked” from “the speculation existed”:
// Field proof, sent from the DESTINATION page. activationStart is the elapsed
// time the hidden renderer got before the swap, so it is literally the lead
// time your eagerness bought — a value far below the destination's cold LCP
// means the level is firing too late for prerender, whatever DevTools showed.
addEventListener('load', () => {
const nav = performance.getEntriesByType('navigation')[0];
navigator.sendBeacon('/rum/spec', JSON.stringify({
activated: nav.activationStart > 0, // prerender reached activation
leadMs: Math.round(nav.activationStart), // ms the hidden load ran pre-click
ttfb: Math.round(nav.responseStart - nav.requestStart),
delivery: nav.deliveryType || 'network', // 'navigational-prefetch' on a prefetch hit
}));
});
A healthy prerender rule produces a leadMs distribution concentrated at or above the destination’s cold LCP. A leadMs median of 200 ms means you are paying for hidden renderers and buying a 200 ms head start you could have had from a prefetch at a fraction of the cost.
Deterministic Fix Protocol
The target is not one well-chosen level; it is three rules, each at the level its trigger can actually fund, drawing from pools that cannot evict each other.
Work the steps in order and re-measure after each; two changes at once destroys the attribution.
-
[ ] Step 1 — Measure the lead time your triggers actually get. Instrument
pointerover→clickandpointerdown→clickon production traffic, bucketed byPointerEvent.pointerType. You want the median and the p25, per input type. Touch has no hover at all, so on a touch-dominant audiencemoderatecollapses toconservativeandeagernever fires on entry — the numbers you tune against must reflect that mix, not a desktop session.// Rationale: eagerness picks one of these two distributions. Measuring them is // the only way to know whether a level can fund the action you attached to it. const t = new WeakMap(); addEventListener('pointerover', (e) => { const a = e.target.closest?.('a[href]'); if (a && !t.has(a)) t.set(a, { over: performance.now() }); }, { capture: true }); addEventListener('pointerdown', (e) => { const a = e.target.closest?.('a[href]'); if (a) Object.assign(t.get(a) ?? t.set(a, {}).get(a), { down: performance.now() }); }, { capture: true }); addEventListener('click', (e) => { const a = e.target.closest?.('a[href]'); const m = a && t.get(a); if (!m) return; navigator.sendBeacon('/rum/intent', JSON.stringify({ hoverLead: m.over ? Math.round(performance.now() - m.over) : null, downLead: m.down ? Math.round(performance.now() - m.down) : null, pointerType: e.pointerType || 'unknown', })); }, { capture: true }); -
[ ] Step 2 — Measure the work each action needs, on the destination. Load the destination cold with the cache disabled. Record TTFB plus document transfer for the prefetch budget, and time to Largest Contentful Paint for the prerender budget. Read the phase bars rather than the totals — the technique is covered in decoding the DevTools network waterfall. If your prerender budget exceeds 1.5 s, no eagerness level short of
immediatewill make it instant, andimmediateis only honest when the destination is genuinely the next step. -
[ ] Step 3 — Choose the action first, then the cheapest level that funds it. Compare step 1’s median lead against step 2’s requirement. Prefetch at 320 ms is funded by
moderateandeager, partially funded byconservative. Prerender at 1,150 ms is funded byimmediatealone. When nothing funds prerender, the correct move is to drop to prefetch — not to raise eagerness, which buys 200 ms of lead in exchange for multiplying the candidate count. -
[ ] Step 4 — Scope every prerender rule with
selector_matches. Cap the matched set at two or three links so pool B’s two slots are never oversubscribed and the FIFO path is unreachable.href_matcheson a path prefix is almost always too broad for prerender, because it matches whatever the template happens to render today.{ "prerender": [ { "where": { "selector_matches": ".hero a, .top-story a" }, "eagerness": "moderate", "tag": "hero-prerender" } ] }The
tagis not decoration: it is echoed in DevTools and in theSec-Speculation-Tagsrequest header, which is how you attribute origin load to a specific rule once several are live. -
[ ] Step 5 — Keep the broad rule on prefetch, at
conservativeormoderate. Breadth is cheap for prefetch and ruinous for prerender. Exclude side-effecting URLs withnotconditions in the same rule; a speculatedGETthat mutates state is the one genuinely dangerous failure in this API, and it is independent of the level you pick. -
[ ] Step 6 — Layer the rules; do not escalate one rule. Ship the three-rule shape above. Because pool A and pool B are independent, the
immediatecheckout candidate cannot be evicted by hover activity, and the scoped prerender rule cannot be evicted by the broad prefetch rule — prefetch and prerender have separate slots even within pool B. -
[ ] Step 7 — Verify that eviction is gone. DevTools → Application → Speculative loads. Sweep the pointer across the page the way a real user browses a grid, and watch the Speculations list. No candidate should leave Ready, and no failure reason should mention the candidate limit. If one does, the matched set for that rule is still too wide — tighten the selector, do not lower the level.
-
[ ] Step 8 — Re-measure in the field and watch both sides of the trade. Segment RUM by
activationStart > 0and bydeliveryType === 'navigational-prefetch', and track the origin request multiplier — speculative requests divided by real navigations — beside the navigation win. A rule that halves LCP while quadrupling origin traffic has not been tuned; it has been moved onto someone else’s budget. The same runtime gating patterns from dynamic hint injection apply if you need to vary the level by connection quality.
Before / After Metrics
Measured on the 12-card reproduction template: Chromium, Fast 4G emulation, cold cache, 200 simulated browse-and-click sessions. “Before” is variant B — one unscoped moderate prerender rule. “After” is the three-rule layout from step 6. No destination page was changed, no asset resized.
| Measurement | Where to read it | Before | After | Delta |
|---|---|---|---|---|
| Prerenders started per session | Speculative loads, Speculations | 5.2 | 2.0 | −62% |
| Prerenders evicted before the click | Speculations, failure reason | 4.1 | 0.0 | −4.1 |
| Speculative bytes per session | Network summary bar | 1.42 MB | 0.61 MB | −57% |
| Origin request multiplier | server logs, Sec-Purpose filtered |
5.2× | 1.9× | −3.3× |
activationStart on clicked links |
RUM, navigation timing | 180 ms | 1,240 ms | +1,060 ms |
| Navigations activated fully rendered | RUM, activated share |
8% | 71% | +63 pts |
| LCP on speculated clicks (p75) | RUM, segmented | 1,090 ms | 60 ms | −94% |
| TTFB on non-prerendered clicks (p75) | RUM, segmented | 320 ms | 225 ms | −95 ms |
| Renderer processes at peak | Chrome Task Manager | 6 | 3 | −3 |
The row that carries the argument is activationStart. Nothing about the destination got faster — same HTML, same subresources, same server — but the hidden renderer now gets 1,240 ms of lead instead of 180 ms, which is the difference between activating a blank frame and swapping in a finished page. The second-order win is the origin multiplier falling from 5.2× to 1.9×: the tuned rule set is not just faster, it is affordable, which is what keeps it in production after the first traffic spike.
FAQ
Why does raising eagerness from moderate to eager sometimes make navigations slower?
Because eager moves the rule from the two-slot pool into the 50-prefetch, 10-prerender pool, and it fires on pointer entry with no dwell requirement. A pointer travelling to the ninth card in a grid crosses eight links on the way and opens a candidate for each. Those candidates are low priority on the wire, but they queue behind each other on the same connection and hit the same origin, so the candidate that matters starts earlier and then finishes later. For prerender the effect is sharper still, because each candidate is a renderer process competing for CPU with the page the user is currently reading.
Does conservative save anything if the prefetch cannot finish in 95 ms?
Yes, and this is the most misunderstood part of the API. The navigation adopts the in-flight speculative request rather than racing it, so every millisecond of head start is banked: with a 320 ms origin TTFB, a 95 ms pointerdown lead still lands the response 95 ms early, on every internal click, for essentially no waste. What conservative cannot do is deliver an instant navigation, and it cannot fund prerender — a hidden renderer 95 ms into a 1,150 ms load activates part-built and finishes rendering after the swap, which is a worse experience than a plain navigation because the paint arrives in two stages.
Do a moderate prefetch rule and a conservative prefetch rule fight over the same two slots?
They do — both draw from pool B’s two prefetch slots. In practice it is harmless, because the conservative candidate is created at pointerdown and FIFO evicts the oldest, so the newest and most intentful candidate always survives. The pairing that genuinely hurts is a broad moderate prerender rule sitting beside a scoped one: the broad rule’s hover candidates evict the scoped rule’s hidden renderers seconds into their load, and you lose the expensive one to save the cheap one. If you keep both, scope the broad rule to prefetch and leave prerender exclusively to the narrow rule, as the rules in migrating from a prefetch library are arranged.
Related
- Speculation Rules: Prefetch & Prerender — parent topic: rule syntax, the prerender lifecycle,
Sec-Purposesemantics and analytics guards - Migrating from Quicklink to Speculation Rules — sibling guide: replacing a viewport-based prefetch library with the rule shapes tuned here