# How it works

> From a page full of links to two or three warmed navigations, and what happens at each step.

<mermaid code="---
config:
  theme: base
  themeVariables:
    fontFamily: ui-monospace, SFMono-Regular, Menlo, monospace
    fontSize: 13px
    actorBkg: transparent
    actorBorder: "#6b7280"
    actorTextColor: "#6b7280"
    actorLineColor: "#6b7280"
    signalColor: "#6b7280"
    signalTextColor: "#6b7280"
    labelBoxBkgColor: transparent
    labelBoxBorderColor: "#6b7280"
    labelTextColor: "#6b7280"
    noteBkgColor: transparent
    noteBorderColor: "#00d492"
    noteTextColor: "#6b7280"
    lineColor: "#6b7280"
---
sequenceDiagram
    participant B as Browser
    participant N as Your Nitro server
    participant J as TypeSafe Jev

    Note over B: collect candidate links l0, l1, l2 …<br/>watch scroll and pointer
    B->>N: POST state, one request in flight
    N->>N: validate, rate limit, look in cache
    N->>J: one request, three questions
    J-->>N: next, soon, exit
    N-->>B: ids and probabilities
    Note over B: policy: thresholds, budgets, guards
    B->>B: effector A, speculation rules, for document navigations
    B->>B: effector B, Nuxt preloading, for in-app navigations
">



</mermaid>

Every failure along that path ends the same way: an empty prediction, and a page that is
exactly as it would have been without the module.

## 1. Candidates

Every `a[href]` with a box is read from the DOM after hydration, after each navigation, and
after a debounced DOM mutation. A link becomes a candidate when it is same-origin, is not the
current page, is not a `download`, is not `data-precog="off"`, and passes `include` and
`exclude`. Survivors are deduped by path, sorted (hinted first, then visible, then document
order), capped at `maxCandidates`, and given opaque ids: `l0`, `l1`, and so on.

The ids matter. Jev is asked to pick one of them, so the model's whole output space is a set
of strings the server wrote itself. Anchor text goes into the request only as the description
of an option, and the server strips backticks and newlines from it first.

## 2. Signals

Alongside the links the client sends a small state: the page path, title, `h1` and
description; the last five paths; time on page, scroll depth and scroll velocity; the pointer
position, velocity, the three links it is heading towards and the one it is over; and
`saveData` and `effectiveType`.

Everything is rounded and clamped. Positions are tenths of the viewport, not pixels. A full
state with thirty candidates is a few kilobytes.

## 3. Timing

A prediction is scheduled when a route finishes, when a scroll settles for 150 ms, when the
pointer changes which link it is closing on, and when the DOM changes. `minIntervalMs` keeps
those from piling up, one request is in flight at a time, and starting a new one aborts the
old one. A response whose candidate list no longer matches the page is thrown away.

Nothing runs before hydration, while the tab is hidden, while the document is itself being
prerendered, on `saveData`, on a `2g` connection, or before consent when you ask for it.

## 4. The questions

One request, three questions, evaluated in parallel:

<table>
<thead>
  <tr>
    <th>
      Key
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Asked
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        next
      </code>
    </td>
    
    <td>
      choice over the candidate ids plus <code>
        none
      </code>
    </td>
    
    <td>
      Which of these links will the visitor click next, if any?
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        soon
      </code>
    </td>
    
    <td>
      score over four levels
    </td>
    
    <td>
      How soon will the visitor open another page?
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        exit
      </code>
    </td>
    
    <td>
      yes/no
    </td>
    
    <td>
      Will the visitor leave the site entirely instead?
    </td>
  </tr>
</tbody>
</table>

The click probability of a link is `next.probabilities[id]`, discounted by `exit`. `soon.ratio`
decides whether a prerender is worth its cost.

## 5. The policy

<table>
<thead>
  <tr>
    <th>
      Condition
    </th>
    
    <th>
      Action
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        p >= thresholds.prerender
      </code>
      
      , <code>
        soon >= 0.5
      </code>
      
      , mode allows, not <code>
        target="_blank"
      </code>
    </td>
    
    <td>
      prerender, up to <code>
        maxPrerender
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        p >= thresholds.prefetch
      </code>
    </td>
    
    <td>
      prefetch, up to <code>
        maxPrefetch
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      otherwise
    </td>
    
    <td>
      nothing
    </td>
  </tr>
</tbody>
</table>

A link that is already being speculated keeps its slot while it stays above three quarters of
its threshold, so a wobbling probability does not cancel and restart the same load.

## 6. The two effectors

Nuxt navigations are handled by the client router, which is not a document navigation, so
document speculation rules do nothing for them. This is measured, not assumed: see
`e2e/spa-vs-document.spec.ts` and `.github/DECISIONS.md`. Hence two effectors.

**A. Speculation rules.** One `<script type="speculationrules" data-precog>`, replaced on every
decision, with `source: "list"`, `eagerness: "immediate"` and `tag: "precog"` so the loads can
be told apart by the `Sec-Speculation-Tags` request header. Without the API, prefetch falls
back to `<link rel="prefetch">` and prerender is skipped. This is what makes a full document
navigation fast: the first landing on a site, a link opened in a new tab, and the experimental
`documentNavigation` mode.

**B. Nuxt preloading.** `preloadPayload` and `preloadRouteComponents` for the chosen routes, in
probability order. This is where the in-app win comes from, and only for routes that have a
payload, which means prerendered routes with `experimental.payloadExtraction`.

## 7. The server

`POST {endpoint}` rebuilds a trusted state from the body rather than trusting its shape: paths
must be rooted and same-origin, candidate ids must be exactly `l<index>`, numbers are clamped,
and anything else is rejected with a reason in `x-precog-reason`. Cross-site callers are
refused. Calls are rate limited per IP with a token bucket, then looked up in Nitro storage
under a key built from the page, the link set, and coarse buckets of the signals. The
`precog:predict` hook can answer instead of Jev; `precog:predicted` sees every fresh answer.

Any failure is a `503` with an empty prediction. The client then applies `fallback` and the
page is untouched.

## Prerendering has side effects

A prerendered page runs its JavaScript before the visitor ever asks for it. Gate anything with
a side effect:

```ts
if (document.prerendering) {
  document.addEventListener("prerenderingchange", start, { once: true });
} else {
  start();
}
```

This is why `mode` is `'prefetch'` by default and `budget.maxPrerender` is 1.

A speculative load is also a real request to your own server, with the visitor's cookies and a
`Sec-Purpose: prefetch` header. Anything with a side effect on `GET` belongs in `exclude`.

## Browser support

- Speculation rules are Chromium only. Elsewhere prefetch falls back to
`<link rel="prefetch">` and prerender is skipped. The Nuxt preloading effector works
everywhere.
- Chrome allows 50 concurrent prefetches and 10 prerenders for `immediate` list rules. The
default budgets are far below that.
- Inline rules need `'inline-speculation-rules'` in your `script-src` if you set a CSP.
