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.

Timeline showing how much lead time before the click each eagerness level provides, against the 320 millisecond a prefetch needs to complete A time axis running from one thousand milliseconds before the click to the click itself. Four lanes show when each eagerness level opens its candidate: immediate at page parse with more than three seconds of lead, eager at six hundred and twenty milliseconds, moderate at four hundred and twenty milliseconds, and conservative at ninety-five milliseconds. A dashed threshold marks the three hundred and twenty milliseconds a prefetch needs, which immediate, eager and moderate clear and conservative does not. A footnote notes that prerender needs one thousand one hundred and fifty milliseconds and is off the scale at every level except immediate. Eagerness decides how much lead time the speculation gets before the click Mouse input on a 40-card listing page; origin TTFB 320 ms, destination cold-cache LCP 1,150 ms −1,000 −800 −600 −400 −200 ms click immediate eager moderate conservative opens at rule parse — over 3,000 ms of lead, zero intent signal pointer enters link · 620 ms 200 ms dwell · 420 ms pointerdown · 95 ms a prefetch that starts left of this line is ready at the click; conservative starts right of it and is adopted in flight Prerender needs 1,150 ms of lead — off this scale at every level but immediate, so it activates part-loaded.

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.

Six snapshots of the two-slot prerender pool during a pointer sweep, showing the candidate the user finally clicks being evicted and restarted Six columns, each showing the two FIFO prerender slots at one moment during a pointer sweep across a card grid. Hovering cards one through four fills and refills both slots, evicting the oldest candidate each time. When the pointer returns to card two its earlier renderer has already been evicted, so it restarts from zero, and the click arrives when it is only one hundred and eighty milliseconds into an eleven hundred and fifty millisecond load. Two prerender slots, FIFO: the sweep destroys the renderer the user wanted Variant B — moderate prerender, unscoped: every card dwelled on for 200 ms opens a candidate t1 · 0 ms hover /p/1 t2 · +260 ms hover /p/2 t3 · +540 ms hover /p/3 t4 · +810 ms hover /p/4 t5 · +1,090 ms back to /p/2 t6 · +1,270 ms click /p/2 slot 1 slot 2 /p/1 loading 0.2 s empty /p/1 loading 0.5 s /p/2 loading 0.0 s /p/2 loading 0.3 s /p/3 loading 0.0 s /p/3 loading 0.3 s /p/4 loading 0.0 s /p/4 loading 0.3 s /p/2 again restart 0.0 s /p/4 discarded /p/2 activates 0.18 s of 1.15 s evicts /p/1 evicts /p/2 evicts /p/3 /p/2 starts over not ready 970 ms still to run Five renderers started, none survived to activation: 1.4 MB spent and the click still waits nearly a second. Scoping the prerender rule to two selector-matched links removes the eviction path entirely. loading restarted after eviction activated part-loaded discarded on navigation

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.

Three layered speculation rules feeding two independent candidate pools, producing a steady state with no eviction On the left, three rules: a list rule at immediate eagerness for the known checkout step, a selector-scoped prerender document rule at moderate eagerness, and a broad prefetch document rule at conservative eagerness. The immediate rule draws from pool A, which allows fifty prefetches and ten prerenders. The moderate and conservative rules draw from pool B, which allows two of each. Because the pools are independent, the steady state on the right has the checkout candidate ready, two top stories prerendered and never evicted, and every other link adopted in flight at the click. Three rules, three levels, two pools that never compete Each rule is sized to the lead time its own trigger provides, and to the work its action needs List rule · immediate prerender /checkout/payment/ the next step is already known 1 candidate, seconds of lead Document rule · moderate prerender, selector_matches ".hero a, .top-story a" 2 candidates of 2 — no churn Document rule · conservative prefetch, href_matches "/*" minus logout and cart paths adopted in flight at the click Pool A immediate + eager 50 prefetch · 10 prerender here: 1 of 10 used Pool B moderate + conservative 2 prefetch · 2 prerender separate pool per action Steady state checkout is ready long before the user gets there two stories prerendered, never evicted mid-load everything else: −95 ms Mixing levels is safe by construction: pool A and pool B are independent, so a hover never evicts the checkout candidate.

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 pointeroverclick and pointerdownclick on production traffic, bucketed by PointerEvent.pointerType. You want the median and the p25, per input type. Touch has no hover at all, so on a touch-dominant audience moderate collapses to conservative and eager never 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 immediate will make it instant, and immediate is 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 moderate and eager, partially funded by conservative. Prerender at 1,150 ms is funded by immediate alone. 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_matches on 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 tag is not decoration: it is echoed in DevTools and in the Sec-Speculation-Tags request 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 conservative or moderate. Breadth is cheap for prefetch and ruinous for prerender. Exclude side-effecting URLs with not conditions in the same rule; a speculated GET that 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 immediate checkout 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 → ApplicationSpeculative 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 > 0 and by deliveryType === '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.