{"schemaVersion":1,"id":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation","title":"Speculation Rules API: Prerendering Pages Before Navigation and Measured Core Web Vitals Impact","description":"A technical examination of the Speculation Rules API for prerender and prefetch, confidence-based speculation strategies, Chrome's implementation details, and measurable Core Web Vitals improvements including INP optimization.","language":"en","datePublished":"2026-03-05","dateModified":null,"contentFormat":"text/markdown","content":"Web Performance\n\n# Speculation Rules API: Prerendering Pages Before Navigation and Measured Core Web Vitals Impact\n\n2026-03-059 min read\n\n[Lucio Durán](<https://lucioduran.com/>)\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/speculation-rules-api-prerender-navigation>)\n\n## The API: How It Works\n\nSpeculation rules are declared as a JSON object inside a ` <script type=\"speculationrules\"> ` tag:\n\n```html\n<script type=\"speculationrules\">\n{\n \"prerender\": [\n {\n \"where\": {\n \"and\": [\n { \"href_matches\": \"/*\" },\n { \"not\": { \"href_matches\": \"/logout\" } },\n { \"not\": { \"href_matches\": \"/api/*\" } },\n { \"not\": { \"selector_matches\": \".no-prerender\" } }\n ]\n },\n \"eagerness\": \"moderate\"\n }\n ],\n \"prefetch\": [\n {\n \"where\": {\n \"href_matches\": \"/blog/*\"\n },\n \"eagerness\": \"conservative\"\n }\n ]\n}\n</script>\n```\n\nThis tells Chrome: \"For any internal link that's not the logout page or an API route, prerender it when the user shows moderate interest (hover for 200ms). For blog links, prefetch them on conservative intent (pointerdown, the moment of click).\"\n\nThe ` where ` clause supports URL pattern matching (` href_matches `) and CSS selector matching (` selector_matches `). You can compose rules with ` and `, ` or `, and ` not `. This is vastly more powerful than the old ` <link rel=prerender> ` which could only target a single URL.\n\n## Eagerness Levels: The Confidence Spectrum\n\nThere are four eagerness levels, each mapping to a different user intent signal:\n\n| Eagerness | Trigger | Typical Lead Time | Use Case |\n| --- | --- | --- | --- |\n| ` immediate ` | Page load | Seconds | Known next navigation (wizard step 2) |\n| ` eager ` | Link becomes visible | Seconds | High-probability navigation |\n| ` moderate ` | Hover (200ms) | 200-800ms | General navigation links |\n| ` conservative ` | Pointerdown/touchstart | 50-200ms | Lower-confidence or expensive pages |\n\nIn practice, ` moderate ` is the sweet spot for most sites. It gives 200-800ms of lead time (hover duration before click), which is enough to fully prerender a typical page. The 200ms threshold filters out drive-by hovers, users quickly scanning a menu without intent to click.\n\nI instrumented hover behavior on a documentation site with 40,000 daily visitors. The data:\n\n```javascript\n// Hover tracking instrumentation\ndocument.querySelectorAll('a[href]').forEach(link => {\n let hoverStart = 0;\n\n link.addEventListener('pointerenter', () => {\n hoverStart = performance.now();\n });\n\n link.addEventListener('click', () => {\n const hoverDuration = performance.now() - hoverStart;\n navigator.sendBeacon('/analytics/hover', JSON.stringify({\n duration: Math.round(hoverDuration),\n url: link.href,\n clicked: true,\n }));\n });\n\n link.addEventListener('pointerleave', () => {\n const hoverDuration = performance.now() - hoverStart;\n if (hoverDuration > 50) {\n navigator.sendBeacon('/analytics/hover', JSON.stringify({\n duration: Math.round(hoverDuration),\n clicked: false,\n }));\n }\n });\n});\n```\n\nResults from 2 weeks of data:\n\n- Median hover-to-click time: 380ms\n- 75th percentile: 620ms\n- 90th percentile: 1,100ms\n- Hover-to-click rate (hover \\> 200ms): 68% of hovers resulted in click\n- Hover-to-click rate (hover \\> 100ms): 52% resulted in click\n\nWith ` moderate ` eagerness (200ms threshold), we correctly predict 68% of navigations. The 32% of wasted prerenders are a bandwidth cost, but Chrome manages prerender lifecycle aggressively, if memory is constrained, it evicts prerendered pages.\n\n## Dynamic Speculation Rules\n\nStatic rules in HTML work for simple cases, but real sites need dynamic rules based on user behavior, A/B tests, or personalization. You can insert speculation rules via JavaScript:\n\n```javascript\nfunction updateSpeculationRules(urls) {\n // Remove existing rules\n document.querySelectorAll('script[type=\"speculationrules\"]')\n .forEach(el => el.remove());\n\n // Insert new rules\n const script = document.createElement('script');\n script.type = 'speculationrules';\n script.textContent = JSON.stringify({\n prerender: [{\n urls: urls.highConfidence,\n eagerness: 'moderate',\n }],\n prefetch: [{\n urls: urls.lowConfidence,\n eagerness: 'conservative',\n }],\n });\n document.head.appendChild(script);\n}\n\n// Example: predict next page based on scroll depth\nfunction predictNextPage() {\n const scrollPercent = window.scrollY / (document.body.scrollHeight - window.innerHeight);\n\n if (scrollPercent > 0.7) {\n // User is near bottom, likely to go to next page\n const nextLink = document.querySelector('a.pagination-next');\n if (nextLink) {\n updateSpeculationRules({\n highConfidence: [nextLink.href],\n lowConfidence: [],\n });\n }\n }\n}\n\nwindow.addEventListener('scroll', debounce(predictNextPage, 500), { passive: true });\n```\n\nI use this pattern on a blog where articles have \"next post\" links. When the user scrolls past 70% of the article, I add a prerender rule for the next post. By the time they reach the bottom and click \"Next,\" the page is already fully rendered.\n\n## Handling Analytics and Side Effects\n\nThe biggest production gotcha with prerender is side effects. Prerendered pages execute JavaScript. If your analytics fires ` page_view ` during prerender, you'll double-count. If your A/B test SDK assigns a variant during prerender, users might see the wrong variant flash briefly on activation.\n\nChrome provides the ` document.prerendering ` property and the ` prerenderingchange ` event:\n\n```javascript\n// analytics.js, gate analytics on actual page activation\nfunction trackPageView() {\n if (document.prerendering) {\n // Don't track yet, wait for activation\n document.addEventListener('prerenderingchange', () => {\n sendPageView();\n }, { once: true });\n } else {\n sendPageView();\n }\n}\n\nfunction sendPageView() {\n const activationStart = performance.getEntriesByType('navigation')[0]?.activationStart || 0;\n\n gtag('event', 'page_view', {\n page_location: window.location.href,\n page_title: document.title,\n prerendered: activationStart > 0,\n activation_delay_ms: activationStart > 0\n ? Math.round(performance.now() - activationStart)\n : 0,\n });\n}\n\ntrackPageView();\n```\n\nThe ` activationStart ` from the Navigation Timing API tells you how long the page was prerendered before the user actually navigated to it. This is invaluable for measuring speculation effectiveness.\n\nFor ad-heavy pages, the situation is more nuanced. Chrome defers Intersection Observer callbacks (used by ad viewability SDKs) until activation, so ads won't count as \"viewed\" during prerender. But some ad scripts use ` setTimeout ` or scroll-based triggers that don't respect the prerender lifecycle. I ended up wrapping our ad initialization:\n\n```javascript\nfunction initAds() {\n if (document.prerendering) {\n document.addEventListener('prerenderingchange', () => {\n requestAnimationFrame(() => {\n loadAdSlots();\n });\n }, { once: true });\n return;\n }\n loadAdSlots();\n}\n```\n\n## Core Web Vitals: The Measured Impact\n\nI ran a controlled experiment on a content site with ~200,000 monthly visitors. Two weeks without speculation rules (control), two weeks with speculation rules (experiment). CrUX data from Chrome User Experience Report:\n\n### Control Period (no speculation):\n\n| Metric | p75 | Good (%) |\n| --- | --- | --- |\n| LCP | 2.1s | 72% |\n| INP | 180ms | 85% |\n| CLS | 0.04 | 94% |\n\n### Experiment Period (speculation rules, moderate eagerness):\n\n| Metric | p75 | Good (%) |\n| --- | --- | --- |\n| LCP | 0.8s | 94% |\n| INP | 95ms | 96% |\n| CLS | 0.02 | 97% |\n\nLCP dropped from 2.1s to 0.8s at the 75th percentile. That's not a typo. When a page is prerendered, the \"navigation\" is essentially swapping a hidden tab to the foreground. The LCP element is already painted. The biggest remaining LCP cost is the activation swap itself, which takes 20-50ms.\n\nINP improved from 180ms to 95ms. This one surprised me. The improvement comes from the fact that prerendered pages have already completed initial JavaScript parsing and execution. Event handlers are already registered. The main thread is idle when the user arrives. First interaction has no JavaScript boot-up overhead.\n\nCLS improved slightly because prerendered pages have already completed their layout shifts during the hidden prerender phase. By activation time, the layout is stable.\n\nThe bandwidth cost was measurable but reasonable: ~15% increase in total data transfer per session. On average, we prerendered 2.3 pages per session, of which 1.6 were actually visited (69% hit rate). The wasted 0.7 prerenders per session amounted to about 400KB of extra transfer.\n\n## INP Optimization: The Prerender Connection\n\nThis deserves its own section because the INP improvement from prerender is often underappreciated.\n\nINP (Interaction to Next Paint) measures the time from a user interaction (click, tap, keypress) to the next visual update. On a traditional navigation, the first interaction on a new page often has elevated INP because:\n\n1. The browser is still parsing and executing JavaScript\n2. The main thread is busy with layout and paint from the initial render\n3. Event handlers may not be registered yet\n4. Third-party scripts are still loading\n\nWith prerender, all of that happens during the hidden prerender phase. By the time the user interacts with the activated page, the main thread is idle, event handlers are registered, and third-party scripts have finished their initialization.\n\nI measured first-interaction INP specifically:\n\n```javascript\n// Measure INP for the first interaction after activation\nlet firstInteractionRecorded = false;\n\nnew PerformanceObserver((list) => {\n for (const entry of list.getEntries()) {\n if (!firstInteractionRecorded && entry.entryType === 'event') {\n firstInteractionRecorded = true;\n\n const wasPrerendered = performance.getEntriesByType('navigation')[0]?.activationStart > 0;\n\n navigator.sendBeacon('/analytics/inp', JSON.stringify({\n inp: Math.round(entry.duration),\n prerendered: wasPrerendered,\n interactionType: entry.name,\n timeToInteraction: Math.round(entry.startTime),\n }));\n }\n }\n}).observe({ type: 'event', buffered: true, durationThreshold: 16 });\n```\n\nFirst-interaction INP results:\n\n- **Without prerender**: Median 142ms, p75 215ms, p95 380ms\n- **With prerender**: Median 48ms, p75 82ms, p95 140ms\n\nThe p95 improvement from 380ms to 140ms is significant. Those tail cases are typically caused by pages with heavy JavaScript initialization that blocks the main thread during the first interaction window. Prerender eliminates that entire class of INP regression.\n\n## Debugging Speculation Rules\n\nChrome DevTools has a dedicated panel for speculation rules debugging. Open DevTools → Application → Speculative loads. You'll see:\n\n- **Speculation rules**: The parsed rules from your page\n- **Speculations**: Active prefetch/prerender operations with status\n- **Failure reasons**: Why a speculation failed (CSP violation, ` Cache-Control: no-store `, cross-origin restrictions, etc.)\n\nCommon failure reasons I've encountered:\n\n1. **` Cache-Control: no-store `**: Pages with this header cannot be prerendered. This caught me on our login page, even though I excluded ` /login ` from the rules, a redirect from ` /dashboard ` to ` /login ` (for unauthenticated users) was being prerendered as ` /dashboard `, which then failed.\n2. **Cross-origin navigations**: Prerender doesn't work cross-origin by default. If your links go to ` blog.example.com ` but your rules are on ` www.example.com `, they won't match.\n3. **Service worker interference**: If your service worker intercepts navigation requests and returns a custom response, prerender may fail or produce stale content.\n\n## Implementation for Different Frameworks\n\nFor Next.js, add speculation rules in your layout:\n\n```jsx\n// app/layout.js\nexport default function RootLayout({ children }) {\n return (\n <html>\n <head>\n <script\n type=\"speculationrules\"\n dangerouslySetInnerHTML={{\n __html: JSON.stringify({\n prerender: [{\n where: {\n and: [\n { href_matches: \"/*\" },\n { not: { href_matches: \"/api/*\" } },\n { not: { href_matches: \"/auth/*\" } },\n ],\n },\n eagerness: \"moderate\",\n }],\n }),\n }}\n />\n </head>\n <body>{children}</body>\n </html>\n );\n}\n```\n\nFor Astro, it's even simpler since most pages are static:\n\n```astro\n---\n// src/layouts/Base.astro\n---\n<html>\n <head>\n <script type=\"speculationrules\" is:inline>\n {\n \"prerender\": [{\n \"where\": { \"href_matches\": \"/*\" },\n \"eagerness\": \"moderate\"\n }]\n }\n </script>\n </head>\n <body><slot /></body>\n</html>\n```\n\nThe Speculation Rules API is the biggest single improvement I've made to perceived navigation performance in years. The implementation is straightforward, a JSON blob in your HTML. The impact is immediate and measurable in CrUX data within 28 days. The gotchas around analytics and side effects are manageable with the ` document.prerendering ` API.\n\nIf you care about Core Web Vitals, and if you're reading this, you probably do, this should be on your implementation list this quarter. The LCP improvement alone is worth the effort. The INP improvement is the bonus that makes it transformative.\n\n## Frequently asked questions\n\n### Does the Speculation Rules API work in all browsers?\n\nNo. As of early 2026, full prerender support is only in Chromium-based browsers (Chrome, Edge, Opera). Safari and Firefox support the prefetch portion but not full prerender. The API is designed to be a progressive enhancement, browsers that don't support it simply ignore the speculation rules, and navigation works normally.\n\n### How does prerender differ from the old link rel=prerender?\n\nThe old link rel=prerender was deprecated because it was a binary hint with no granularity, the browser would fully prerender one URL or nothing. The Speculation Rules API adds document rules (pattern matching), eagerness levels, confidence scoring, and the ability to specify multiple URLs with different strategies. It also works correctly with browser resource management, not consuming excessive memory.\n\n### Can prerender cause issues with analytics or ad tracking?\n\nYes, this is a real concern. Prerendered pages execute JavaScript, which means analytics scripts will fire during prerender. Chrome delays certain APIs (like Intersection Observer triggers for ad viewability) until the page is actually activated, but not all analytics providers handle this correctly. Use document.prerendering and the prerenderingchange event to gate analytics calls.\n\n## Tips\n\n1. **LuchoTip:** Start with ` prefetch ` rules before jumping to ` prerender `. Prefetch only downloads the document (5-50KB typically), while prerender downloads and executes everything, stylesheets, scripts, images. On a page with 2MB of assets, a wrong prerender guess costs real bandwidth. Use ` prefetch ` for moderate confidence and ` prerender ` only for high confidence navigations.\n2. **LuchoTip:** Always add ` eagerness: \"moderate\" ` to your speculation rules in production instead of ` \"eager\" `. Eager fires immediately on page load for all matching URLs, which can trigger dozens of prerenders simultaneously on link-heavy pages. Moderate waits for hover intent (200ms threshold), which filters out accidental hovers and saves 80% of wasted speculations.\n3. **LuchoTip:** Test your speculation rules with Chrome DevTools → Application → Speculative loads tab. It shows which URLs are being prerendered, their status, and failure reasons. I've caught broken speculation rules that silently failed because the target page had ` Cache-Control: no-store `, which blocks prerender entirely.\n\n- speculation-rules\n- prerender\n- prefetch\n- core-web-vitals\n- inp\n- web-performance\n- chrome\n- navigation\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Speculation Rules API: Prerendering Pages Before Navigation and Measured Core Web Vitals Impact","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#title"},{"level":2,"text":"The API: How It Works","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#the-api-how-it-works"},{"level":2,"text":"Eagerness Levels: The Confidence Spectrum","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#eagerness-levels-the-confidence-spectrum"},{"level":2,"text":"Dynamic Speculation Rules","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#dynamic-speculation-rules"},{"level":2,"text":"Handling Analytics and Side Effects","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#handling-analytics-and-side-effects"},{"level":2,"text":"Core Web Vitals: The Measured Impact","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#core-web-vitals-the-measured-impact"},{"level":3,"text":"Control Period (no speculation):","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#control-period-no-speculation"},{"level":3,"text":"Experiment Period (speculation rules, moderate eagerness):","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#experiment-period-speculation-rules-moderate-eagerness"},{"level":2,"text":"INP Optimization: The Prerender Connection","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#inp-optimization-the-prerender-connection"},{"level":2,"text":"Debugging Speculation Rules","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#debugging-speculation-rules"},{"level":2,"text":"Implementation for Different Frameworks","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#implementation-for-different-frameworks"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#questions"},{"level":3,"text":"Does the Speculation Rules API work in all browsers?","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#question-1"},{"level":3,"text":"How does prerender differ from the old link rel=prerender?","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#question-2"},{"level":3,"text":"Can prerender cause issues with analytics or ad tracking?","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#article-tips"}],"sections":[{"heading":"Speculation Rules API: Prerendering Pages Before Navigation and Measured Core Web Vitals Impact","level":1,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation","content":"Web Performance"},{"heading":"Speculation Rules API: Prerendering Pages Before Navigation and Measured Core Web Vitals Impact","level":1,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#title","content":"2026-03-059 min read\n\n[Lucio Durán](<https://lucioduran.com/>)\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/speculation-rules-api-prerender-navigation>)"},{"heading":"The API: How It Works","level":2,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#the-api-how-it-works","content":"Speculation rules are declared as a JSON object inside a ` <script type=\"speculationrules\"> ` tag:\n\n```html\n<script type=\"speculationrules\">\n{\n \"prerender\": [\n {\n \"where\": {\n \"and\": [\n { \"href_matches\": \"/*\" },\n { \"not\": { \"href_matches\": \"/logout\" } },\n { \"not\": { \"href_matches\": \"/api/*\" } },\n { \"not\": { \"selector_matches\": \".no-prerender\" } }\n ]\n },\n \"eagerness\": \"moderate\"\n }\n ],\n \"prefetch\": [\n {\n \"where\": {\n \"href_matches\": \"/blog/*\"\n },\n \"eagerness\": \"conservative\"\n }\n ]\n}\n</script>\n```\n\nThis tells Chrome: \"For any internal link that's not the logout page or an API route, prerender it when the user shows moderate interest (hover for 200ms). For blog links, prefetch them on conservative intent (pointerdown, the moment of click).\"\n\nThe ` where ` clause supports URL pattern matching (` href_matches `) and CSS selector matching (` selector_matches `). You can compose rules with ` and `, ` or `, and ` not `. This is vastly more powerful than the old ` <link rel=prerender> ` which could only target a single URL."},{"heading":"Eagerness Levels: The Confidence Spectrum","level":2,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#eagerness-levels-the-confidence-spectrum","content":"There are four eagerness levels, each mapping to a different user intent signal:\n\n| Eagerness | Trigger | Typical Lead Time | Use Case |\n| --- | --- | --- | --- |\n| ` immediate ` | Page load | Seconds | Known next navigation (wizard step 2) |\n| ` eager ` | Link becomes visible | Seconds | High-probability navigation |\n| ` moderate ` | Hover (200ms) | 200-800ms | General navigation links |\n| ` conservative ` | Pointerdown/touchstart | 50-200ms | Lower-confidence or expensive pages |\n\nIn practice, ` moderate ` is the sweet spot for most sites. It gives 200-800ms of lead time (hover duration before click), which is enough to fully prerender a typical page. The 200ms threshold filters out drive-by hovers, users quickly scanning a menu without intent to click.\n\nI instrumented hover behavior on a documentation site with 40,000 daily visitors. The data:\n\n```javascript\n// Hover tracking instrumentation\ndocument.querySelectorAll('a[href]').forEach(link => {\n let hoverStart = 0;\n\n link.addEventListener('pointerenter', () => {\n hoverStart = performance.now();\n });\n\n link.addEventListener('click', () => {\n const hoverDuration = performance.now() - hoverStart;\n navigator.sendBeacon('/analytics/hover', JSON.stringify({\n duration: Math.round(hoverDuration),\n url: link.href,\n clicked: true,\n }));\n });\n\n link.addEventListener('pointerleave', () => {\n const hoverDuration = performance.now() - hoverStart;\n if (hoverDuration > 50) {\n navigator.sendBeacon('/analytics/hover', JSON.stringify({\n duration: Math.round(hoverDuration),\n clicked: false,\n }));\n }\n });\n});\n```\n\nResults from 2 weeks of data:\n\n- Median hover-to-click time: 380ms\n- 75th percentile: 620ms\n- 90th percentile: 1,100ms\n- Hover-to-click rate (hover \\> 200ms): 68% of hovers resulted in click\n- Hover-to-click rate (hover \\> 100ms): 52% resulted in click\n\nWith ` moderate ` eagerness (200ms threshold), we correctly predict 68% of navigations. The 32% of wasted prerenders are a bandwidth cost, but Chrome manages prerender lifecycle aggressively, if memory is constrained, it evicts prerendered pages."},{"heading":"Dynamic Speculation Rules","level":2,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#dynamic-speculation-rules","content":"Static rules in HTML work for simple cases, but real sites need dynamic rules based on user behavior, A/B tests, or personalization. You can insert speculation rules via JavaScript:\n\n```javascript\nfunction updateSpeculationRules(urls) {\n // Remove existing rules\n document.querySelectorAll('script[type=\"speculationrules\"]')\n .forEach(el => el.remove());\n\n // Insert new rules\n const script = document.createElement('script');\n script.type = 'speculationrules';\n script.textContent = JSON.stringify({\n prerender: [{\n urls: urls.highConfidence,\n eagerness: 'moderate',\n }],\n prefetch: [{\n urls: urls.lowConfidence,\n eagerness: 'conservative',\n }],\n });\n document.head.appendChild(script);\n}\n\n// Example: predict next page based on scroll depth\nfunction predictNextPage() {\n const scrollPercent = window.scrollY / (document.body.scrollHeight - window.innerHeight);\n\n if (scrollPercent > 0.7) {\n // User is near bottom, likely to go to next page\n const nextLink = document.querySelector('a.pagination-next');\n if (nextLink) {\n updateSpeculationRules({\n highConfidence: [nextLink.href],\n lowConfidence: [],\n });\n }\n }\n}\n\nwindow.addEventListener('scroll', debounce(predictNextPage, 500), { passive: true });\n```\n\nI use this pattern on a blog where articles have \"next post\" links. When the user scrolls past 70% of the article, I add a prerender rule for the next post. By the time they reach the bottom and click \"Next,\" the page is already fully rendered."},{"heading":"Handling Analytics and Side Effects","level":2,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#handling-analytics-and-side-effects","content":"The biggest production gotcha with prerender is side effects. Prerendered pages execute JavaScript. If your analytics fires ` page_view ` during prerender, you'll double-count. If your A/B test SDK assigns a variant during prerender, users might see the wrong variant flash briefly on activation.\n\nChrome provides the ` document.prerendering ` property and the ` prerenderingchange ` event:\n\n```javascript\n// analytics.js, gate analytics on actual page activation\nfunction trackPageView() {\n if (document.prerendering) {\n // Don't track yet, wait for activation\n document.addEventListener('prerenderingchange', () => {\n sendPageView();\n }, { once: true });\n } else {\n sendPageView();\n }\n}\n\nfunction sendPageView() {\n const activationStart = performance.getEntriesByType('navigation')[0]?.activationStart || 0;\n\n gtag('event', 'page_view', {\n page_location: window.location.href,\n page_title: document.title,\n prerendered: activationStart > 0,\n activation_delay_ms: activationStart > 0\n ? Math.round(performance.now() - activationStart)\n : 0,\n });\n}\n\ntrackPageView();\n```\n\nThe ` activationStart ` from the Navigation Timing API tells you how long the page was prerendered before the user actually navigated to it. This is invaluable for measuring speculation effectiveness.\n\nFor ad-heavy pages, the situation is more nuanced. Chrome defers Intersection Observer callbacks (used by ad viewability SDKs) until activation, so ads won't count as \"viewed\" during prerender. But some ad scripts use ` setTimeout ` or scroll-based triggers that don't respect the prerender lifecycle. I ended up wrapping our ad initialization:\n\n```javascript\nfunction initAds() {\n if (document.prerendering) {\n document.addEventListener('prerenderingchange', () => {\n requestAnimationFrame(() => {\n loadAdSlots();\n });\n }, { once: true });\n return;\n }\n loadAdSlots();\n}\n```"},{"heading":"Core Web Vitals: The Measured Impact","level":2,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#core-web-vitals-the-measured-impact","content":"I ran a controlled experiment on a content site with ~200,000 monthly visitors. Two weeks without speculation rules (control), two weeks with speculation rules (experiment). CrUX data from Chrome User Experience Report:"},{"heading":"Control Period (no speculation):","level":3,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#control-period-no-speculation","content":"| Metric | p75 | Good (%) |\n| --- | --- | --- |\n| LCP | 2.1s | 72% |\n| INP | 180ms | 85% |\n| CLS | 0.04 | 94% |"},{"heading":"Experiment Period (speculation rules, moderate eagerness):","level":3,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#experiment-period-speculation-rules-moderate-eagerness","content":"| Metric | p75 | Good (%) |\n| --- | --- | --- |\n| LCP | 0.8s | 94% |\n| INP | 95ms | 96% |\n| CLS | 0.02 | 97% |\n\nLCP dropped from 2.1s to 0.8s at the 75th percentile. That's not a typo. When a page is prerendered, the \"navigation\" is essentially swapping a hidden tab to the foreground. The LCP element is already painted. The biggest remaining LCP cost is the activation swap itself, which takes 20-50ms.\n\nINP improved from 180ms to 95ms. This one surprised me. The improvement comes from the fact that prerendered pages have already completed initial JavaScript parsing and execution. Event handlers are already registered. The main thread is idle when the user arrives. First interaction has no JavaScript boot-up overhead.\n\nCLS improved slightly because prerendered pages have already completed their layout shifts during the hidden prerender phase. By activation time, the layout is stable.\n\nThe bandwidth cost was measurable but reasonable: ~15% increase in total data transfer per session. On average, we prerendered 2.3 pages per session, of which 1.6 were actually visited (69% hit rate). The wasted 0.7 prerenders per session amounted to about 400KB of extra transfer."},{"heading":"INP Optimization: The Prerender Connection","level":2,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#inp-optimization-the-prerender-connection","content":"This deserves its own section because the INP improvement from prerender is often underappreciated.\n\nINP (Interaction to Next Paint) measures the time from a user interaction (click, tap, keypress) to the next visual update. On a traditional navigation, the first interaction on a new page often has elevated INP because:\n\n1. The browser is still parsing and executing JavaScript\n2. The main thread is busy with layout and paint from the initial render\n3. Event handlers may not be registered yet\n4. Third-party scripts are still loading\n\nWith prerender, all of that happens during the hidden prerender phase. By the time the user interacts with the activated page, the main thread is idle, event handlers are registered, and third-party scripts have finished their initialization.\n\nI measured first-interaction INP specifically:\n\n```javascript\n// Measure INP for the first interaction after activation\nlet firstInteractionRecorded = false;\n\nnew PerformanceObserver((list) => {\n for (const entry of list.getEntries()) {\n if (!firstInteractionRecorded && entry.entryType === 'event') {\n firstInteractionRecorded = true;\n\n const wasPrerendered = performance.getEntriesByType('navigation')[0]?.activationStart > 0;\n\n navigator.sendBeacon('/analytics/inp', JSON.stringify({\n inp: Math.round(entry.duration),\n prerendered: wasPrerendered,\n interactionType: entry.name,\n timeToInteraction: Math.round(entry.startTime),\n }));\n }\n }\n}).observe({ type: 'event', buffered: true, durationThreshold: 16 });\n```\n\nFirst-interaction INP results:\n\n- **Without prerender**: Median 142ms, p75 215ms, p95 380ms\n- **With prerender**: Median 48ms, p75 82ms, p95 140ms\n\nThe p95 improvement from 380ms to 140ms is significant. Those tail cases are typically caused by pages with heavy JavaScript initialization that blocks the main thread during the first interaction window. Prerender eliminates that entire class of INP regression."},{"heading":"Debugging Speculation Rules","level":2,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#debugging-speculation-rules","content":"Chrome DevTools has a dedicated panel for speculation rules debugging. Open DevTools → Application → Speculative loads. You'll see:\n\n- **Speculation rules**: The parsed rules from your page\n- **Speculations**: Active prefetch/prerender operations with status\n- **Failure reasons**: Why a speculation failed (CSP violation, ` Cache-Control: no-store `, cross-origin restrictions, etc.)\n\nCommon failure reasons I've encountered:\n\n1. **` Cache-Control: no-store `**: Pages with this header cannot be prerendered. This caught me on our login page, even though I excluded ` /login ` from the rules, a redirect from ` /dashboard ` to ` /login ` (for unauthenticated users) was being prerendered as ` /dashboard `, which then failed.\n2. **Cross-origin navigations**: Prerender doesn't work cross-origin by default. If your links go to ` blog.example.com ` but your rules are on ` www.example.com `, they won't match.\n3. **Service worker interference**: If your service worker intercepts navigation requests and returns a custom response, prerender may fail or produce stale content."},{"heading":"Implementation for Different Frameworks","level":2,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#implementation-for-different-frameworks","content":"For Next.js, add speculation rules in your layout:\n\n```jsx\n// app/layout.js\nexport default function RootLayout({ children }) {\n return (\n <html>\n <head>\n <script\n type=\"speculationrules\"\n dangerouslySetInnerHTML={{\n __html: JSON.stringify({\n prerender: [{\n where: {\n and: [\n { href_matches: \"/*\" },\n { not: { href_matches: \"/api/*\" } },\n { not: { href_matches: \"/auth/*\" } },\n ],\n },\n eagerness: \"moderate\",\n }],\n }),\n }}\n />\n </head>\n <body>{children}</body>\n </html>\n );\n}\n```\n\nFor Astro, it's even simpler since most pages are static:\n\n```astro\n---\n// src/layouts/Base.astro\n---\n<html>\n <head>\n <script type=\"speculationrules\" is:inline>\n {\n \"prerender\": [{\n \"where\": { \"href_matches\": \"/*\" },\n \"eagerness\": \"moderate\"\n }]\n }\n </script>\n </head>\n <body><slot /></body>\n</html>\n```\n\nThe Speculation Rules API is the biggest single improvement I've made to perceived navigation performance in years. The implementation is straightforward, a JSON blob in your HTML. The impact is immediate and measurable in CrUX data within 28 days. The gotchas around analytics and side effects are manageable with the ` document.prerendering ` API.\n\nIf you care about Core Web Vitals, and if you're reading this, you probably do, this should be on your implementation list this quarter. The LCP improvement alone is worth the effort. The INP improvement is the bonus that makes it transformative."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#questions","content":""},{"heading":"Does the Speculation Rules API work in all browsers?","level":3,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#question-1","content":"No. As of early 2026, full prerender support is only in Chromium-based browsers (Chrome, Edge, Opera). Safari and Firefox support the prefetch portion but not full prerender. The API is designed to be a progressive enhancement, browsers that don't support it simply ignore the speculation rules, and navigation works normally."},{"heading":"How does prerender differ from the old link rel=prerender?","level":3,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#question-2","content":"The old link rel=prerender was deprecated because it was a binary hint with no granularity, the browser would fully prerender one URL or nothing. The Speculation Rules API adds document rules (pattern matching), eagerness levels, confidence scoring, and the ability to specify multiple URLs with different strategies. It also works correctly with browser resource management, not consuming excessive memory."},{"heading":"Can prerender cause issues with analytics or ad tracking?","level":3,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#question-3","content":"Yes, this is a real concern. Prerendered pages execute JavaScript, which means analytics scripts will fire during prerender. Chrome delays certain APIs (like Intersection Observer triggers for ad viewability) until the page is actually activated, but not all analytics providers handle this correctly. Use document.prerendering and the prerenderingchange event to gate analytics calls."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#article-tips","content":"1. **LuchoTip:** Start with ` prefetch ` rules before jumping to ` prerender `. Prefetch only downloads the document (5-50KB typically), while prerender downloads and executes everything, stylesheets, scripts, images. On a page with 2MB of assets, a wrong prerender guess costs real bandwidth. Use ` prefetch ` for moderate confidence and ` prerender ` only for high confidence navigations.\n2. **LuchoTip:** Always add ` eagerness: \"moderate\" ` to your speculation rules in production instead of ` \"eager\" `. Eager fires immediately on page load for all matching URLs, which can trigger dozens of prerenders simultaneously on link-heavy pages. Moderate waits for hover intent (200ms threshold), which filters out accidental hovers and saves 80% of wasted speculations.\n3. **LuchoTip:** Test your speculation rules with Chrome DevTools → Application → Speculative loads tab. It shows which URLs are being prerendered, their status, and failure reasons. I've caught broken speculation rules that silently failed because the target page had ` Cache-Control: no-store `, which blocks prerender entirely.\n\n- speculation-rules\n- prerender\n- prefetch\n- core-web-vitals\n- inp\n- web-performance\n- chrome\n- navigation\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Lucio Durán","url":"https://lucioduran.com/"},{"text":"Español","url":"https://lucioduran.com/es/blog/speculation-rules-api-prerender-navigation"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com/"},{"@type":"WebSite","@id":"https://lucioduran.com/#website","url":"https://lucioduran.com/","name":"Lucio Durán","inLanguage":["en","es"],"publisher":{"@id":"https://lucioduran.com/#person"}},{"@type":"WebPage","@id":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#page","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation","name":"Speculation Rules API: Prerendering Pages Before Navigation and Measured Core Web Vitals Impact | Lucio Durán","description":"A technical examination of the Speculation Rules API for prerender and prefetch, confidence-based speculation strategies, Chrome's implementation details, and measurable Core Web Vitals improvements including INP optimization.","inLanguage":"en","author":{"@id":"https://lucioduran.com/#person"},"publisher":{"@id":"https://lucioduran.com/#person"},"isPartOf":{"@id":"https://lucioduran.com/#website"},"isAccessibleForFree":true,"datePublished":"2026-03-05","mainEntity":{"@id":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#article"},"breadcrumb":{"@id":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#breadcrumb"}},{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#article","mainEntityOfPage":{"@id":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#page"},"headline":"Speculation Rules API: Prerendering Pages Before Navigation and Measured Core Web Vitals Impact","description":"A technical examination of the Speculation Rules API for prerender and prefetch, confidence-based speculation strategies, Chrome's implementation details, and measurable Core Web Vitals improvements including INP optimization.","datePublished":"2026-03-05","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@id":"https://lucioduran.com/#person"},"url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Web Performance","keywords":"speculation-rules, prerender, prefetch, core-web-vitals, inp, web-performance, chrome, navigation","wordCount":1636,"timeRequired":"PT9M","workTranslation":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/speculation-rules-api-prerender-navigation#article","inLanguage":"es","url":"https://lucioduran.com/es/blog/speculation-rules-api-prerender-navigation"}],"isAccessibleForFree":true},{"@type":"Blog","@id":"https://lucioduran.com/blog#blog","url":"https://lucioduran.com/blog","name":"Blog","inLanguage":"en","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com/"},"publisher":{"@id":"https://lucioduran.com/#person"},"isPartOf":{"@id":"https://lucioduran.com/#website"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Speculation Rules API: Prerendering Pages Before Navigation and Measured Core Web Vitals Impact","item":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation"}],"@id":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#breadcrumb"},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Does the Speculation Rules API work in all browsers?","acceptedAnswer":{"@type":"Answer","text":"No. As of early 2026, full prerender support is only in Chromium-based browsers (Chrome, Edge, Opera). Safari and Firefox support the prefetch portion but not full prerender. The API is designed to be a progressive enhancement, browsers that don't support it simply ignore the speculation rules, and navigation works normally."}},{"@type":"Question","name":"How does prerender differ from the old link rel=prerender?","acceptedAnswer":{"@type":"Answer","text":"The old link rel=prerender was deprecated because it was a binary hint with no granularity, the browser would fully prerender one URL or nothing. The Speculation Rules API adds document rules (pattern matching), eagerness levels, confidence scoring, and the ability to specify multiple URLs with different strategies. It also works correctly with browser resource management, not consuming excessive memory."}},{"@type":"Question","name":"Can prerender cause issues with analytics or ad tracking?","acceptedAnswer":{"@type":"Answer","text":"Yes, this is a real concern. Prerendered pages execute JavaScript, which means analytics scripts will fire during prerender. Chrome delays certain APIs (like Intersection Observer triggers for ad viewability) until the page is actually activated, but not all analytics providers handle this correctly. Use document.prerendering and the prerenderingchange event to gate analytics calls."}}],"@id":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#questions","url":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#questions","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation#page"},"author":{"@id":"https://lucioduran.com/#person"}}]}],"contentHash":"2fce0860f3ea4f841c0e7857d760264482a8a07e60d909979e04b66acef9b58c","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/speculation-rules-api-prerender-navigation","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":78929,"contentBytes":15649}}
