Fix Inflated Pageviews From Link Prefetching and Speculative Prerendering
EngineeringAugust 15, 202612 min read

Fix Inflated Pageviews From Link Prefetching and Speculative Prerendering

Speculation Rules and prefetch APIs are silently doubling your pageview counts. Here's the detection method and the fix.

Two weeks ago a developer posted a screenshot in a Slack channel I'm in. Their blog showed 8,400 pageviews for July. Google Search Console showed 2,100 clicks to those same pages. Not even close. Attribution wasn't the gap — they weren't running ads. Bots weren't the answer — their traffic patterns were clean. The 4x discrepancy was inflated pageviews from prefetch.

Their Next.js app had automatic link prefetching enabled. Every link a user hovered over triggered a background fetch. Every fetch ran their analytics script. Every script execution fired a pageview. Readers scrolling through the homepage — hovering over twelve article links before clicking one — generated twelve pageviews for content they never read.

And yeah, I've shipped this exact bug myself. Twice. The second time was after I thought I'd fixed it.

What's actually happening

Modern browsers are aggressive about prefetching. The goal is good: start loading pages before users click so navigations feel instant. The execution breaks naive analytics.

There are three mechanisms to understand:

Link prefetch (<link rel="prefetch">) tells the browser to fetch a resource during idle time. The HTML loads. Scripts execute. But the user hasn't navigated — the page exists in a hidden cache, waiting. If your analytics fires on DOMContentLoaded or script execution, it counts this invisible load as a pageview.

Next.js/Astro/Nuxt automatic prefetching wraps this in framework magic. Hover over a <Link> component, and the framework fetches the destination. Same problem. The page "loads" in the background. Your analytics never knew to wait.

Speculation Rules API (Chrome 109+, Edge 109+) takes this further. You declare which pages to prerender — not just fetch, but fully render in a hidden tab. The DOM builds. Scripts run. Event handlers attach. Everything executes except the page is invisible. A user who hovers over three links might have three fully-rendered pages running simultaneously, each one logging a pageview to your dashboard.

The Speculation Rules API is the worst offender because prerendered pages aren't just fetched — they're alive. Your analytics script loads, initializes, and fires. The user is still on the previous page. From your dashboard's perspective, they're now in two places at once.

How bad is the inflation?

Depends on your prefetch strategy. I've audited sites where the gap ranged from 15% to 40% inflated pageviews. Here's the rough math:

  • Average links visible on a typical page: 8-15
  • Percentage of those that get prefetched on hover or viewport entry: 30-60% (depending on framework settings)
  • Percentage of prefetched pages the user actually visits: 10-25%

So if your page has 10 links, 5 get prefetched, and users click 1 of those 5, you've recorded 5 pageviews for 1 actual visit. Multiply across sessions. The numbers add up fast.

The teams hit hardest: content sites with lots of internal links, e-commerce with product grids, SaaS dashboards with navigation-heavy UIs. If your homepage has 20 links and Next.js prefetches on hover, you're in trouble. This is especially problematic if you're trying to correlate analytics with error tracking — garbage in, garbage out.

One telling symptom: your bounce rate looks impossibly low. Users "visiting" five pages but only actually viewing one skews the math. Another symptom: session durations that don't make sense. A "3-page session" that lasted 8 seconds because two of those pages were prefetched and never seen.

This drove me nuts for a while. I kept optimizing content based on "engagement" that wasn't real.

If you're correlating analytics with click fraud detection, inflated pageviews also throw off your conversion rate calculations. More "pageviews" with the same conversions makes legitimate traffic look lower-performing than it is.

Detecting the problem in your data

Before you fix anything, confirm you have the issue. Here's the diagnostic process.

Step 1: Check your browser's prefetch activity

Open Chrome DevTools, go to the Network panel, and browse your site normally. Watch for requests that fire before you click. Hover over navigation links. Scroll so new links enter the viewport. If you see page requests firing without corresponding clicks, prefetch is active.

Look for requests with an Sec-Purpose: prefetch header (for prefetch) or pages loading in the Application panel under "Preloaded pages" (for Speculation Rules prerendering).

Step 2: Compare analytics to navigation-derived metrics

Pull two numbers:

  1. Total pageviews from your analytics dashboard
  2. Total navigation events (or unique sessions multiplied by average pages-per-session from your raw logs)

If pageviews exceed navigations by more than 10%, prefetch inflation is likely.

For a quick sanity check, compare your analytics pageview count to Google Search Console clicks for the same period. GSC only counts actual arrivals from search. If your analytics shows 3x the GSC numbers for organic traffic, something's counting ghosts. If you're migrating from GA4, this is the perfect time to implement visibility-aware tracking from day one.

Step 3: Check for prerendering state

Add this debug snippet to your analytics initialization:

console.log('Analytics init state:', {
  prerendering: document.prerendering,
  visibilityState: document.visibilityState,
  activationStart: performance.getEntriesByType('navigation')[0]?.activationStart
})

Load a page that gets prefetched. If prerendering is true or visibilityState is hidden when your analytics fires, you've found the bug. The script ran before the user arrived.

The fix: visibility-aware pageview tracking

The solution is simple: don't fire pageviews until the page is actually visible. Here's the pattern.

For prerendering (Speculation Rules)

function trackPageview() {
  window.ja?.('pageview', { url: location.pathname + location.search })
}

if (document.prerendering) {
  // Page is being prerendered — wait for actual activation
  document.addEventListener('prerenderingchange', () => {
    trackPageview()
  }, { once: true })
} else {
  // Normal load — track immediately
  trackPageview()
}

The prerenderingchange event fires when a prerendered page gets activated — meaning the user actually navigated to it. Until that event fires, the page exists but hasn't been visited. No event, no pageview.

For prefetch and general visibility

Prefetch doesn't trigger prerendering state, but the page might still load in a hidden tab (user opened link in background, browser restored a tab, etc.). The broader pattern:

function trackPageview() {
  window.ja?.('pageview', { url: location.pathname + location.search })
}

function initTracking() {
  if (document.prerendering) {
    document.addEventListener('prerenderingchange', trackPageview, { once: true })
  } else if (document.visibilityState === 'hidden') {
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') {
        trackPageview()
      }
    }, { once: true })
  } else {
    trackPageview()
  }
}

initTracking()

This handles three cases:

  1. Prerendered pages wait for prerenderingchange
  2. Hidden pages (background tabs) wait for visibilitychange
  3. Visible pages fire immediately

The { once: true } option removes the listener after it fires. No cleanup needed, no memory leaks, no double-firing if someone rapidly switches tabs.

Framework-specific implementations

Next.js App Router:

// components/Analytics.tsx
'use client'

import { useEffect, useRef } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'

export function Analytics() {
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const hasFired = useRef<string | null>(null)

  useEffect(() => {
    const url = pathname + (searchParams?.toString() ? `?${searchParams}` : '')

    // Skip if already fired for this URL
    if (hasFired.current === url) return

    function trackPageview() {
      if (hasFired.current === url) return
      hasFired.current = url
      window.ja?.('pageview', { url })
    }

    // Handle prerendering
    if (document.prerendering) {
      document.addEventListener('prerenderingchange', trackPageview, { once: true })
      return
    }

    // Handle hidden tabs
    if (document.visibilityState === 'hidden') {
      const handler = () => {
        if (document.visibilityState === 'visible') {
          trackPageview()
        }
      }
      document.addEventListener('visibilitychange', handler, { once: true })
      return () => document.removeEventListener('visibilitychange', handler)
    }

    // Normal case — visible and not prerendered
    trackPageview()
  }, [pathname, searchParams])

  return null
}

Mount this in your root layout inside a Suspense boundary (because useSearchParams requires it). For a complete setup guide including this pattern, see our Next.js 15 analytics integration tutorial. If you're already using an idempotent pageview pattern from our React Strict Mode double-fire fix, you can merge the visibility check into that existing component.

Astro with View Transitions:

---
// src/components/Analytics.astro
---
<script>
  function trackPageview() {
    window.ja?.('pageview', { url: location.pathname + location.search })
  }

  function initTracking() {
    if (document.prerendering) {
      document.addEventListener('prerenderingchange', trackPageview, { once: true })
    } else if (document.visibilityState === 'hidden') {
      document.addEventListener('visibilitychange', () => {
        if (document.visibilityState === 'visible') trackPageview()
      }, { once: true })
    } else {
      trackPageview()
    }
  }

  // Run on initial load
  initTracking()

  // Re-run after View Transitions
  document.addEventListener('astro:page-load', initTracking)
</script>

Astro's View Transitions swap the page content without a full reload. The astro:page-load event fires after each transition. Hook it to re-initialize tracking — but the visibility check still applies. A prerendered page that gets view-transitioned into existence should wait for activation.

Disabling aggressive prefetch (optional)

Sometimes the right answer is to prefetch less. If you're prefetching 20 links and users click 2, you're burning bandwidth and server resources for an 8x waste ratio.

Next.js: Set prefetch={false} on Link components, or configure the router to prefetch only on hover instead of viewport entry:

// Disable prefetch on specific links
<Link href="/pricing" prefetch={false}>Pricing</Link>

// Or globally in next.config.js (Next.js 13+):
module.exports = {
  experimental: {
    linkPrefetch: 'intent' // Only on hover/focus, not viewport
  }
}

Speculation Rules: If you're using manual Speculation Rules, tighten the selector:

<script type="speculationrules">
{
  "prerender": [
    {
      "where": { "selector_matches": ".prerender-eligible" },
      "eagerness": "moderate"
    }
  ]
}
</script>

The eagerness setting (immediate, eager, moderate, conservative) controls how aggressively Chrome prerenders. moderate waits for hover intent. conservative only prerenders after a clear navigation signal.

But honestly? I'd fix the analytics first and leave prefetch enabled. Faster navigations benefit users. Broken analytics is the bug — prefetch is the feature working as intended. The systems just needed to talk to each other.

Hot take: most "analytics best practices" guides completely ignore this. They'll spend 500 words on UTM parameters and zero on whether their pageviews are even real.

Verifying the fix

After implementing visibility-aware tracking:

  1. Open your site in Chrome
  2. Open DevTools Network panel
  3. Hover over links that trigger prefetch (watch for requests)
  4. Navigate away from those links without clicking them
  5. Check your analytics dashboard

The prefetched pages should show zero pageviews. Only pages you actually visited should appear.

For Speculation Rules prerendering specifically, Chrome DevTools has a dedicated panel: Application > Background Services > Speculative Loads. You can see which pages are prerendered and their activation status. If a page shows "prerendered but not activated," it should have zero analytics events.

Run this test for a week and compare your pageview-to-session ratio. Before the fix, you might see 4.2 pageviews per session. After, maybe 2.8 — which is probably the real number. More accurate data means better decisions. Less noise means cleaner funnels. This matters whether you're running a single consolidated observability stack or a multi-tool setup.

I've watched teams rebuild entire marketing strategies after realizing their "high-traffic" pages were prefetch ghosts. Painful conversations. Worth having.

And if you're testing across multiple browser profiles or comparing behavior between Chrome (which prerenders) and Safari (which doesn't), JustBrowser lets you run isolated sessions without the incognito-window shuffle.

Common errors and how to fix them

Pageviews dropped to zero. Your visibility check is too aggressive. Make sure you're handling the normal case (visible, not prerendered) and not just the edge cases. The snippet should fire immediately when conditions are normal.

Some prerendered pages still count. Check if your framework hydrates before your analytics script runs. The page might flip from prerendering: true to prerendering: false before your check executes. Move the check earlier — inline script in <head> instead of deferred module.

Old browsers break. document.prerendering doesn't exist in older browsers. Guard it:

if (typeof document.prerendering !== 'undefined' && document.prerendering) {
  // Handle prerendering
} else {
  // Normal path
}

SPA route changes don't apply visibility check. The visibility pattern is for initial page loads. Client-side navigations (pushState, framework router) happen within an already-visible page. Those should fire immediately using the SPA route change tracking pattern. If you're using Django or Rails on the backend, the server-side setup is different — see our Django middleware guide or Rails 7 Hotwire tutorial for those frameworks.

Frequently Asked Questions

Does prefetching always cause inflated pageviews?

Only if your analytics fires on script load instead of visibility. Prefetch loads the HTML and runs scripts in a hidden context. Analytics tools that fire immediately — without checking document.prerendering or visibilityState — count that as a real pageview. The user might never actually navigate to that page. They hovered over a link, the browser speculated, and your dashboard recorded a visit that never happened.

Which browsers support the Speculation Rules API?

Chrome 109+ and Edge 109+ shipped Speculation Rules. Firefox and Safari haven't implemented it as of August 2026. But even without Speculation Rules, older prefetch mechanisms (rel=prefetch, Next.js automatic prefetching) still cause similar issues. The visibility-aware pattern in this post handles all of them.

Will disabling prefetch hurt my Core Web Vitals?

Probably not as much as you think. Prefetch improves LCP on navigations users actually take. But if you're prefetching ten links and users click one, you've wasted nine requests and potentially inflated your bandwidth costs. The real fix isn't disabling prefetch — it's making your analytics smart enough to ignore prefetched-but-not-visited pages.

How do I know if my current analytics tool handles this correctly?

Test it. Open DevTools Network panel, hover over a link that triggers prefetch (watch for a request to that page's URL), then navigate somewhere else. Check your analytics dashboard. If it recorded a pageview for the prefetched URL you never visited, your tool has the bug. JustAnalytics, Plausible, and Fathom all handle this correctly by default. GA4 does not.


Try JustAnalytics

All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts