Agustin Barrientos
All posts

Instant Navigation on a Static Site with Speculation Rules and View Transitions

Want more of these in your Google results?

When you click an article from the homepage of this site in a browser that supports it, the destination is often loaded before the click lands, and the swap between the two pages fades instead of flashing white. Navigation that smooth normally comes from an application that loads once and then redraws the screen with JavaScript on every click after that, never asking the server for a whole new page. People call that a single-page app, or SPA, and the JavaScript that decides what to redraw for each URL is its router. This site has no router at all, and it navigates that way anyway, because two browser features now cover the effect between them, one for each half of what people mean by “instant”.

The half that gets all the attention is actual latency, how long the destination takes to become ready, and Speculation Rules address it by letting the browser prefetch or fully prerender a likely destination before the click. The other half is perceived continuity, what your eyes get while one document replaces another, and cross-document View Transitions address it by animating that replacement, no client-side router involved.

Both now run on the site you’re reading, and the work that took real thought was deciding which links deserve a prerender. A prerender spends the reader’s bandwidth, memory, and battery on a prediction you can get wrong. A statically generated site makes that look like a non-question, and “prerender every internal link” is the answer this piece argues against. The pages themselves aren’t small, which is worth measuring before you assume it. A typical article here is around 90 KB of gzipped HTML and the heaviest is 490 KB, against a homepage of 6 KB, because every code panel inlines its own highlighted source.

None of this turns a static site into an SPA, and that’s the good news. The links stay links, the URLs stay URLs, the back button keeps working, and browsers that support none of this get the same site they always got.

What you’ll take away

The worked example is this site, but it’s only the case study. By the end you should be able to do all of this on whatever multi-page site you work on.

  • Tell the two halves of “instant navigation” apart, actual latency and perceived continuity, and say which mechanism owns each.
  • Opt two documents into a cross-document view transition that a reader who asked for less motion never gets.
  • Read the eagerness levels as claims about how confident your page is, so the browser retuning its hover timings doesn’t invalidate your rules.
  • Price a prerender in what it actually spends, the reader’s bandwidth, memory, and battery, rather than in page weight.
  • Give every link type on a site a tier you can defend, with at most one pre-interaction prerender, backed by evidence or shipped as the experiment that earns it.
  • Find the scripts a prerender breaks, which is anything that reads user state once at startup, and make them read it again when the reader actually arrives.
  • Prove what the browser really did, from DevTools and from a page that reports on its own navigation.

Two sites can ship the identical JSON block, and one of them speeds its readers up while the other quietly spends their data on guesses. You’ll be able to say which one you’re building.

None of this needs a browser-internals background, and there’s nothing to install. Every term gets explained the first time it matters, and the figures are for poking at rather than studying.

A quick note on letting AI code this

An AI writes this entire feature well, and you should let it. The opt-in is a few lines of CSS, the rules are a small JSON block, and both are documented patterns a model reproduces in seconds. When you ask plainly for “speculation rules for my site”, though, you tend to get the generic version, an immediate prerender across every internal link. Describe the good version instead, one immediate prerender you can defend, browser-triggered tiers for the rest, and guards on the scripts that shouldn’t run invisibly, and the model writes that one just as fast. The leverage is knowing which version to ask for and recognizing which one you got, and that judgment only gains value as the models improve.

Building it

Step 1: Split “instant” into its two problems

The naive framing treats “make navigation instant” as one dial, and the four possible configurations show why that fails.

Table What each mechanism actually fixes. Neither one covers for the other.
ConfigurationLoading starts early?The swap feels continuous?
Normal navigationnono
View transition onlynoyes
Speculation Rules onlyyesno
Both togetheryesyes

A view transition doesn’t touch latency, it politely waits for a slow destination to reach its first render, and Chrome skips the animation entirely once the wait passes roughly four seconds. A prerender doesn’t touch continuity, the destination lands fully ready and the swap is still a hard cut. The Nielsen Norman Group’s response-time limits explain the stakes, since below roughly 100 milliseconds a response reads as instantaneous, a bar only the latency half can meet.

You don’t have to take the table’s word for it. I built a navigation lab for this article, four sets of real, statically served pages where each mode isolates one configuration, and it opens in its own tab because what it tests is top-level navigation. Walk pages A to B to C in each mode with network throttling on, and the table above becomes something you can feel, though throttling slows an invisible prerender exactly as much as a visible load, so on Slow 4G give the DevTools Speculative loads panel time to reach Ready before you click. The lab pages are ordinary documents on this same origin, because Chrome ignores speculation rules declared inside iframes.

Step 2: The continuity half is a CSS opt-in

The continuity half is startlingly small, which is why I ship it first. Both the current page and the destination opt in with one at-rule, and the browser animates the swap on every same-origin navigation between them, with no JavaScript in the basic case.

global.css
/* global.css, the site-wide stylesheet every page already loads */
@media (prefers-reduced-motion: no-preference) {
  @view-transition {
    navigation: auto;
  }

  body > header {
    view-transition-name: site-header;
  }

  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 0.18s;
  }
}

Three decisions live in those few lines.

First, the whole block sits inside prefers-reduced-motion: no-preference, so a reader who asked their system for less motion never opts in at all. That’s stricter than softening the animation, but “the mechanism doesn’t exist for you” is the version I can reason about, and motion preferences are a hard requirement in anything that animates by default.

Second, the default transition is already the right one. The browser fades the old page into the new one, and I only shortened that fade to 180 milliseconds. The @view-transition machinery will happily do hero morphs and directional slides between documents, and for a content site I’d argue against nearly all of it. An animation impressive enough to screenshot is an animation your reader has to sit through on every single navigation, and the job of this one is to read as one continuous surface instead of a white flash.

Third, view-transition-name: site-header, which is easiest to follow by picturing what the browser does during a transition. It takes a screenshot of the old page and a screenshot of the new one, then fades the first into the second. That is a screenshot of the whole page, header included. So the header fades out and back in on every navigation, even though it looks the same on both pages.

That whole-page screenshot is called root, which is the word inside the two selectors in the rule above. view-transition-name lifts an element out of it and gives that element a screenshot of its own. The name you pick is just a label. The browser looks for the same label on both pages. When it finds site-header on each, it treats them as one element that moved rather than two unrelated things. It then animates that element separately, sliding and resizing it from its old position to its new one.

The header here never moves. Its two screenshots are identical and land in the same spot, so there’s nothing to slide and nothing to fade. The header sits still while the content changes underneath it.

A name has to be unique among the elements on screen at any one time. If two of them share a name, the browser skips the transition entirely.

That one line does more for perceived continuity than any easing curve I tried.

The rules of engagement are strict and worth knowing. The navigation must be same-origin, both documents must carry the opt-in, and reloads and address-bar navigations never animate, though back and forward do. Walk the lab’s transition-only mode to see all of this, including the part the table promised, that under throttling the animation holds the old page until the slow destination can paint its first frame, then plays.

Browser support is real but not universal, and a browser without it performs the exact navigation it always performed.

Browser supportview-transition86% of users
Chrome126
Edge126
FirefoxNo
Safari18.2
Safari iOS18.2
Chrome Android126

Browser data updated September 24, 2026

Step 3: Latency, and what a speculation actually spends

The latency half is the Speculation Rules API, a JSON block that tells the browser which destinations are worth preparing and how eagerly. The simplest possible version names one URL.

html
<!-- any page's head: prepare one likely destination completely -->
<script type="speculationrules">
  {
    "prerender": [
      {
        "urls": [
          "/writing/senior-eye/searchable-accordions"
        ],
        "eagerness": "immediate"
      }
    ]
  }
</script>

That block asked for a prerender, but there are two things a rule can ask for, and the other one is a prefetch. I’ll call them the two verbs, because choosing between them is the decision this whole article turns on. They sound like the same idea at different strengths, and they cost wildly different amounts.

A prefetch downloads the destination’s HTML and stops there. No images, no CSS, no scripts, and nothing on the page runs. It isn’t the old <link rel="prefetch">, since the browser handles the response the way it handles a navigation and holds it in memory instead of putting it in the HTTP cache.

A prerender goes all the way. It loads the page in an invisible tab, images and CSS and scripts included, and runs it, so arriving there later is nearly free. That costs roughly what an extra hidden tab costs, in bandwidth, CPU, and memory. Asking for a prerender gets you the prefetch too, since preparing the page starts with fetching it.

Either way, a rule is a request rather than a command. Chrome declines to speculate when Data Saver is on, when the battery is low with Energy Saver active, under memory pressure, when the reader has turned preloading off in settings, and in background tabs. Your rules propose and the browser decides, and that division of authority is what makes shipping this defensible.

Support is the narrowest part of the whole article. Speculation Rules ship in Chromium browsers, Safari has an implementation behind a flag that’s off by default, and Firefox has none. So everything in this half is an enhancement rather than a baseline. When you need to know at runtime, HTMLScriptElement.supports( 'speculationrules' ) is the feature detection. The table below rebuilds that picture from the compat data bundled at build time, and its footer dates the snapshot.

Browser supportspeculationrules77% of users
Chrome109
Edge109
FirefoxNo
SafariFlag
Safari iOSFlag
Chrome Android109

Browser data updated September 24, 2026

Step 4: What the eagerness levels really encode

Every rule carries an eagerness, and the names read as event definitions, hover this long, press that button, but reading them that way is the mistake. They encode how confident the page claims to be, and the browser maps that confidence onto whatever triggers it currently believes in.

Table The eagerness ladder, from the level that claims the most confidence down to the one that claims the least.
EagernessThe confidence it encodesCurrent Chrome triggerCurrent Chrome cap
immediatethe site itself predicts this destinationas soon as the rules are parsed50 prefetches, 10 prerenders
eagerany flicker of user interest is enougha 10 ms hover on desktop, viewport entry heuristics on mobile2, oldest evicted
moderatewait for a stronger signala 200 ms hover, or pointer-down if sooner; scroll-stop heuristics or the press on mobile2, oldest evicted
conservativeact only when the click is startingpointer-down or touch-down2, oldest evicted

The confidence column is the durable part. What each level claims is defined in the spec, so it means the same thing in every browser that ships this.

The two columns headed Current Chrome are exactly that, current and Chrome’s. They hold one browser’s answer to the claim, and another browser can answer differently. Chrome can also change its own answer, which it has already done to eager. Neither of those breaks your rules, because a rule never names a number. It names a confidence level and leaves the browser to decide what that’s worth right now.

So pick the level that matches your confidence, and read the current timings off Chrome’s prerender documentation when you sit down to tune.

One objection to moderate is worth settling here, before that level shows up in the policy. The doubt is that a 200 millisecond hover means the click has already happened, which would make the head start worthless. That’s half right.

moderate has two triggers, not one, and it fires on whichever arrives first. One is the 200 millisecond hover. The other is pointer-down, the moment you press the mouse or trackpad.

That second trigger is easy to miss, because a click is not a single instant. The loading starts when you press. The navigation starts when you let go. Everything between those two moments is head start.

A press is rarely quick. It runs tens to hundreds of milliseconds, the range that decides whether a navigation feels sluggish or instant. So a hover too short to reach 200 milliseconds still leaves the press, and the press alone is worth having. The figure below times your own pointer against both triggers.

The hover and press timings are your real pointer, measured live. The thresholds they are compared against are Chrome's documented desktop behavior, which the browser has retuned before. On touch input there is no hover, which is exactly why Chrome uses viewport heuristics there.

Try a few deliberate approaches and a few accidental ones, because the gap between them is what the tier is trading on. On desktop moderate ignores a pass-through hover under 200 milliseconds, but a longer accidental one looks identical to real interest, and on mobile there’s no hover to read at all. A tier reduces wasted speculation; nothing about it makes the prediction correct.

Now the actual senior work, deciding which links get which tier, because “just prerender everything” fails the moment you price it. A statically generated page is cheap to serve and still costs real client work to prerender. One unused 100 KB prerender is noise, and ten of them on every page view is a megabyte of punished curiosity, which is why Chrome caps the tiers at all, guardrails against extreme misuse rather than a budget to aim for.

The policy this site ships has three tiers. The first two live in the layout every page shares, written as one document-sourced ruleset in the head. Each page inherits them from there without knowing they exist.

html
<!-- In the shared layout's head: the site-wide policy, matched against every link in the page -->
<script type="speculationrules">
  {
    "prerender": [
      {
        "where": {
          "and": [
            { "href_matches": "/writing/senior-eye/*" },
            { "not": { "href_matches": "/writing/senior-eye/page/*" } }
          ]
        },
        "eagerness": "moderate"
      },
      {
        "where": {
          "and": [
            { "href_matches": "/writing/brief/*" },
            { "not": { "href_matches": "/writing/brief/page/*" } }
          ]
        },
        "eagerness": "moderate"
      }
    ],
    "prefetch": [
      {
        "where": {
          "href_matches": [
            "/",
            "/about",
            "/writing",
            "/writing/*"
          ]
        },
        "eagerness": "conservative"
      }
    ]
  }
</script>

The strong tier, in the shared layout, is article links, the cards on listing pages and the newer-and-older links at the bottom of every article. Listing those URLs by hand would mean editing the rules every time I publish, so the where clause matches them by shape instead, and rules written that way are called document rules. They get a moderate prerender, which on desktop starts the full preparation at the 200 millisecond hover or on the press, and on mobile hands the timing to the press or to Chrome’s scroll-stop viewport heuristics.

The cheap tier, also in the shared layout, is everything else internal, the header navigation, the about page, section indexes, pagination, tag pages. Those links appear on every page, which makes any individual one a poor prediction, and a conservative prefetch is priced for exactly that, the cheapest verb on the latest trigger. It still shaves request latency off every navigation, and being wrong costs one HTML response. The /writing/* wildcard crosses path segments, so article links match this prefetch rule on top of their prerender rule, an overlap I kept on purpose, since the press-time prefetch still applies when the browser declined or already evicted the bigger prerender bet.

I considered eager for the article cards and rejected it for this site. On desktop it buys at most a couple hundred milliseconds over moderate, which a light, prerendered-on-hover page rarely needs, and on mobile it fires as cards merely enter the screen mid-scroll. That tradeoff can be right for a site with overwhelming card click-through, and the honest answer is that mine has no evidence to justify it yet.

The third tier is the single exception to browser-decided triggers, and it does not live in the shared layout at all. It sits in the homepage template, so it applies on the homepage and nowhere else.

astro
---
// In the homepage template: the two pre-interaction bets, one verb each
const homepageRules = featured
  ? {
      prerender: [ { 
        urls: [ postUrl( featured ) ], 
        eagerness: 'immediate' 
      } ],
      ...( runnerUp
        ? { 
            prefetch: [ { 
              urls: [ postUrl( runnerUp ) ], 
              eagerness: 'immediate' 
            } ] 
          }
        : {} ),
    }
  : null;
---
{ homepageRules && <script type="speculationrules" is:inline set:html={ JSON.stringify( homepageRules ) } /> }

Two links get spent on before any interaction, and they get different verbs, which is the part worth copying. The featured card takes the expensive one, an immediate prerender, because it’s the single destination worth a hidden tab’s CPU and memory. The runner-up takes the cheap one, an immediate prefetch, which fetches a response body and renders nothing, so its expected lower click-through still clears that much lower bar.

Splitting the verbs that way matters more than the tier names, because the two are limited by different resources. A prefetch costs bandwidth, and bandwidth is the resource that actually did get cheap, which is why Chrome’s cap for immediate prefetch is 50 URLs and its cap for immediate prerender is 10. A prerender costs a hidden document’s styling, image decoding, script execution, and memory on whatever device the reader is holding, and no amount of cheap bandwidth pays that down. Prefetch broadly and prerender narrowly, since one of them spends the resource that’s abundant and the other spends the one that isn’t.

Neither bet has click-through data behind it yet, so both ship as measured experiments rather than proven predictions. If your own analytics already rank your homepage destinations, use that ranking, since the tier is about evidence rather than layout. Note also which page didn’t get promoted. The about page is 7 KB, and a document that small is dominated by round-trip time rather than transfer, so the conservative prefetch it already has fires at pointer-down and typically covers most of the fetch before the navigation commits. Immediate prefetch pays in proportion to how long a document takes to fetch, which means the heavy pages need it and the light ones are already handled. Everything that matches no rule, external links, the RSS feed, the demo pages, gets ordinary navigation, and that’s a decision too.

The lab’s speculation mode runs immediate instead, deliberately, so the DevTools Speculative loads panel always has something to show there, and the combined mode turns both on together, so two clicks show you the halves working as one.

Step 6: What a “static” page still does at runtime

The part of prerendering that actually bit me has nothing to do with JSON. A prerender executes the destination page, and “statically generated” only ever described how my HTML was produced. At runtime, every page of this site selects a theme from localStorage before paint, hides newsletter forms for subscribed readers, upgrades its interactive components, and initializes analytics. All of that now runs early, invisibly, and sometimes for a page the reader never ends up visiting.

Two of those needed real guards, and the platform’s signal for both is document.prerendering plus its prerenderingchange event, which fires at the moment an invisible page becomes the visible one.

js
// The shared layout's inline theme script
const apply = () => {
  try {
    const saved = localStorage.getItem('ab-theme');
    const sys = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
    document.documentElement.dataset.theme = saved || sys;
  } catch (e) {
    document.documentElement.dataset.theme = 'light';
  }
};
apply();
// A prerendered copy of this page runs this script ahead of the click, so the theme it
// read can be stale by the time the user arrives; re-read the choice at activation.
if (document.prerendering) {
  document.addEventListener('prerenderingchange', apply, { once: true });
}

The reader opens an article, the next article prerenders, the reader flips the site to dark mode, then clicks through, and without the guard the new page arrives wearing the stale light theme it read at prerender time. Any “read user state once at startup” script has this class of bug under prerendering, so do the work, and if you’re prerendering, redo it at activation.

Analytics got the same treatment with the opposite motivation.

js
// The shared layout's analytics loader
// gtag itself holds its page view until a prerendered page is activated; deferring the
// library load too keeps a prerender that never activates from spending bandwidth on it.
if (document.prerendering) {
  document.addEventListener('prerenderingchange', schedule, { once: true });
} else {
  schedule();
}

Google Analytics defers a prerendered page view until activation by default, so the numbers were already safe. What the guard saves is the download and boot of the analytics library for a page view that may never happen, which is thrift rather than correctness.

Table Who counts a prerender that never gets activated.
SystemCounts it?Why
Server and CDN logsusually yesthe fetch really happened, unless you classify it by its Sec-Purpose header
Google Analytics 4nothe page view is deferred until activation
Search Console clicksnono result was clicked
A human beingnonobody saw the page

Only the first row of that table asks anything of you, and the tool for it is a request header. The browser sends Sec-Purpose on every speculative request, with the value prefetch when it is only fetching and prefetch;prerender when it is prerendering. A real visit carries no such header at all.

That difference is what lets a log pipeline tell speculative fetches apart from real ones. It also lets the server turn a speculation down. Answering a request that carries the header with anything other than a success status cancels the speculation, which is how a page opts out from the server side.

Two more scripts had the same problem, found by checking everything else on the site that reads user state at startup. The theme switch in the header reads data-theme when it connects, and the Focus reader control in the article toolbar reads localStorage. Both now re-read their state on activation, through the same once-only prerenderingchange listener the theme script uses.

Not every case needed a guard. The heavy demos already sit behind a run-on-click wall, so a prerendered article boots only the light ones. The flag that hides newsletter forms from subscribed readers can go stale exactly the way the theme could, and I left it that way on purpose. The worst it produces is a signup form shown to somebody who already subscribed, which costs them a glance. The real work in this step is deciding which staleness earns a guard and which earns a shrug.

Step 7: Make the invisible work observable

Everything so far happens invisibly by design, which is exactly why I don’t trust it without instruments. The browser’s own instrument is DevTools, in the Application panel under Background services. Its Speculative loads section has three views, one for the current page’s own prerender status, one for the rule sets the page declared, and one for every candidate URL with its status and failure reason. The renderer dropdown in the DevTools toolbar can even switch you into the invisible prerendered page to inspect its network and console before activation.

The page itself can also testify. An activated prerender leaves a fingerprint, a positive activationStart on the navigation timing entry, measuring how long the invisible render ran before the reader arrived. A used prefetch leaves one too, since deliveryType on the same entry reads navigational-prefetch when the navigation was served from the speculation cache.

TypeScript’s DOM library doesn’t declare either signal yet, or document.prerendering, or the prerenderingchange event. You declare them yourself, once, in an ambient file documented as the temporary shim it is, which keeps the reading free of as casts.

prerender.d.ts
// prerender.d.ts, the declarations the DOM lib doesn't ship yet
interface Document {

	/**
	 * True while this document is being prerendered in an invisible context.
	 *
	 * Shipped in Chromium and not yet in TypeScript's DOM lib, so it is declared here as an optional ambient member.
	 * Reading it in a browser without the feature yields `undefined`, which every guard should treat as false.
	 */
	readonly prerendering?: boolean;
}

interface PerformanceNavigationTiming {

	/**
	 * Milliseconds between the navigation start of a prerender and the moment the user actually navigated to it.
	 *
	 * Zero for a normal navigation, positive for an activated prerender.
	 * Shipped in Chromium and not yet in TypeScript's DOM lib, so it is declared here as an optional ambient member.
	 */
	readonly activationStart?: number;
}

interface PerformanceResourceTiming {

	/**
	 * How the response behind this entry was delivered, `navigational-prefetch` when it came from the Speculation Rules prefetch cache.
	 *
	 * Shipped in Chromium and not yet in TypeScript's DOM lib, so it is declared here as an optional ambient member.
	 * Reading it in a non-supporting browser yields `undefined`, which the strict comparison treats as false.
	 */
	readonly deliveryType?: string;
}

interface DocumentEventMap {

	/**
	 * Fired on a prerendered document at the moment the user navigates to it and it becomes visible.
	 *
	 * Declared here so `document.addEventListener( 'prerenderingchange', ... )` is typed without a cast.
	 */
	prerenderingchange: Event;
}

With those in place, one function answers everything worth knowing about the current navigation, and the timing entry is narrowed with instanceof rather than a cast.

nav-facts.ts
// nav-facts.ts, what a page can observe about its own navigation
/**
 * Reads support for both APIs and what the browser actually did with this navigation.
 *
 * `activationStart` is zero on an ordinary navigation and positive on an activated prerender, so a single reading answers both whether the page was prerendered and for how long.
 * `deliveryType` reads `navigational-prefetch` when the navigation was served from a prefetched response, which makes the cheap verb as observable as the expensive one.
 * Every value is observed from the live document, never simulated, so the reading stays honest in a browser that supports none of this.
 */
export function readNavigationFacts() {
	const entry = performance.getEntriesByType( 'navigation' )[ 0 ];
	const activationStart = entry instanceof PerformanceNavigationTiming ? ( entry.activationStart ?? 0 ) : 0;

	return {
		speculationRules: 'supports' in HTMLScriptElement && HTMLScriptElement.supports( 'speculationrules' ),
		crossDocumentViewTransitions: 'CSSViewTransitionRule' in window,
		prerenderingNow: document.prerendering === true,
		wasPrerendered: activationStart > 0,
		activationDelay: activationStart > 0 ? activationStart : null,
		usedPrefetchedResponse: entry instanceof PerformanceNavigationTiming && entry.deliveryType === 'navigational-prefetch',
		reducedMotion: matchMedia( '(prefers-reduced-motion: reduce)' ).matches,
	};
}

One line in there needs a word of explanation. Every other check asks the browser a direct question about a feature, and for cross-document view transitions there isn’t a reliable one to ask. The feature is an at-rule, @view-transition, and CSS.supports() has no cross-browser way to test at-rules. So the check looks for something that arrives with the feature instead. A browser that supports the rule also exposes a DOM interface for it, CSSViewTransitionRule, so finding that name on window answers the question indirectly but reliably.

Most of those values are settled once the page loads, but two can change while the reader is still on it. A prerendered document activates partway through, which is what turns wasPrerendered true. A reader can also switch their motion preference mid-visit, which flips reducedMotion. So anything displaying this readout has to read it again when either happens, on prerenderingchange for the first and on the media query’s own change event for the second.

Three places report on this work, and each answers a different question. DevTools shows what a page asked for and what the browser decided about each one, including the requests it turned down, which never reach the network at all. The Sec-Purpose header shows the speculative requests that did reach the server. activationStart and deliveryType, read from the page itself, show which navigations actually used one.

On this site that prefetch row turns yes when a navigation used a prefetched response the browser never upgraded to a prerender. Three situations produce that. The homepage’s runner-up card is prefetched and never prerendered. Any link the conservative tier catches gets a prefetch at pointer-down. And a prerender that was started and then abandoned leaves its prefetch behind.

The figure below is that readout, running live on this page.

It sits in an iframe, and Chrome ignores speculation rules declared inside a subframe, so the experiment itself can’t run in here. The prerender rows still work, because they report on the iframe’s own document rather than on this article. What they say depends on how you got here.

On an ordinary visit the frame loads lazily while you read, and both rows report no. When this article was itself prerendered, from the homepage’s featured-card rule or from a hovered article link, the answer changes. Lazy loading is a scheduling hint rather than a promise, so the browser was free to fetch this same-origin frame during that invisible render. The rows then report the prerender they rode along with, which is Step 6’s lesson demonstrating itself.

The same readout sits on every page of the lab. There the speculation and combined modes turn its prerender row green from the second page of your walk, since the lab’s hub page declares no rules of its own.

Every row is observed from the live document, never simulated. This embed is an iframe and Chrome ignores speculation rules inside subframes, so no rules run here, but the prerender rows still answer for the frame itself, no on an ordinary visit and yes when the article was prerendered and loaded the frame invisibly with it. Open the navigation lab in its own tab and walk the speculation or combined mode past its first page to watch the rules themselves at work.

Where the SPA comparison honestly ends

With both halves shipped, the site navigates like the SPAs I’ve built, and it still isn’t one, in ways worth stating. There’s no persistent in-memory state between pages, no application shell that survives navigation, no partial DOM replacement, and no route interception, every navigation still tears down one document and activates another. If your product genuinely needs those, editors, dashboards, anything with heavy cross-screen state, you still want an application architecture. What changed is the burden of proof for content sites, since the two qualities people usually reach for an SPA to get turned out to be things the browser now provides across real documents.

The same honesty applies to what to speculate at all. Pages whose GET has side effects, sign-out URLs, add-to-cart endpoints, one-time tokens, anything that mutates state or burns an allowance, must never appear in a prerender rule, since a prerendered sign-out link logs the reader out invisibly and a prefetched one-time URL burns its token. Exclusion lists in the where clause exist exactly for these. Pages that render user-specific or rapidly changing server state need either an exclusion of their own or a server that checks Sec-Purpose and declines. A static content site is close to the best case for this API precisely because none of its pages do anything when fetched.

A few ways this goes wrong

  • Answering “bandwidth is cheap now” with a prerender. It’s a fair point about prefetch, where the whole cost is a response body, and it’s the wrong answer for prerender, which runs a full invisible document on the reader’s device.
  • Opting only one document into the transition. Cross-document view transitions need @view-transition on both sides, so a missing opt-in on the destination silently gives you the old hard cut.
  • Letting analytics count invisible visits. GA defers to activation, but a self-hosted tracker or a different provider may fire on load during a prerender, so verify with document.prerendering on the client and Sec-Purpose in your server logs before trusting a traffic graph.

What separates a senior from a junior here

  • Splitting the feeling into its two mechanisms. A junior hears “make navigation instant” and reaches for one tool, usually a router. A senior separates actual latency from perceived continuity and ships the two smallest things that address each. The review question is which of the two a change is actually buying.
  • Pricing the prediction instead of the page. The junior version is immediate on every internal link, because each page is small. The senior version spends in proportion to the evidence that one specific navigation happens. The review question is what evidence justifies this eagerness level.
  • Reading vendor heuristics as weather. A junior memorizes the 200 milliseconds, and a senior keeps a note in the repo about what to re-verify. The review question is which numbers in this diff are spec and which are this quarter’s defaults.
  • Refusing to ship invisible behavior without instruments. Everything here is designed to be unobservable, so a junior ships it and hopes, while a senior builds the observation first, the DevTools workflow, the server-log split between requested and used, and a page that can testify about its own navigation. The review question is how anyone will tell a requested speculation from an activated one once this merges.

Pick the verb by the resource it spends, then pick the tier by the evidence you have.

An immediate prerender belongs on the one destination your analytics say most readers of that page go to next, and one such link per page is the ceiling. Without that data, ship at most one anyway, name it an experiment, and let it earn or lose the slot on the numbers it produces. The links whose click-through is real but not dominant, article cards and the newer-and-older links, take a moderate prerender, where the browser’s own trigger decides when the bet is worth placing.

Prefetch can afford to be generous, since a response body is the one resource that genuinely did get cheap, and Chrome’s own caps encode the same asymmetry. So the runner-up destinations on a page are worth an immediate prefetch even without proof, and the rest of the reading routes take a conservative prefetch at pointer-down. Everything that isn’t a reading route stays off the list entirely, because a tier you can’t justify is a tier you shouldn’t declare. Nothing goes to external links, and a URL whose GET changes something gets excluded outright.

The transition half needs no confidence policy at all, since two declarations cost nothing until a navigation happens and a reader who asked for less motion never opts in.

The hover thresholds are the part of this API people argue about, and they’re the part that matters least. What you’re really setting is how much of the reader’s device you’re willing to spend on a guess, and the answer differs by an order of magnitude between the two verbs.

Want more of these in your Google results?