The Problem
Client's GA4 reporting started showing a scroll-to-pageview ratio of around 2.0, climbing on mobile to nearly 2.4. That should not be possible. GA4 fires the enhanced measurement scroll event once per page when the user reaches 90% vertical depth. Even a perfectly engaged audience caps at 1.0.
I caught it on a Looker Studio dashboard where the scroll engagement KPI was being used as a quality signal for content prioritisation. The team had been writing more long-form pages on the assumption that they were getting double the scroll completion of the short ones. They were getting the same completion as the short ones, counted twice.
Real-time debug view confirmed it:
event_name: page_view timestamp: 14:02:01.244
event_name: scroll timestamp: 14:02:09.118
event_name: scroll timestamp: 14:02:09.121 <-- duplicate
event_name: page_view timestamp: 14:02:34.802 (soft navigation)
event_name: scroll timestamp: 14:02:41.560
event_name: scroll timestamp: 14:02:41.562 <-- duplicate
Two scroll events fire within four milliseconds of each other, every time, on every page, including the first one. So it was not a soft-navigation duplication issue. Something was double-binding the scroll listener on initial mount.
Why It Happens
The site was a Next.js 16 App Router build with the standard pattern: a global gtag script in the root layout, and a <GoogleAnalytics> component that re-fires page_view on every client-side route change. Two things conspired to double the scroll events.
The first is that the gtag scroll listener is installed by enhanced measurement when the page loads. It binds a passive scroll handler to the window. The handler reports once per "page" when the threshold is crossed, where "page" is tracked internally by a counter that resets on gtag('event', 'page_view', ...). So far, so normal.
The second is that the App Router layout was re-mounting the Google Analytics script under a <Suspense> boundary that suspended on a streaming server component. When the boundary resolved, the script was injected a second time, and the GA4 runtime bound the scroll listener a second time without unbinding the first. The two listeners both share the same internal page counter, but each one independently calls gtag('event', 'scroll', ...) when its own threshold check passes. Both checks pass at almost the same moment, which is why the timestamps were two to four milliseconds apart.
This shows up on App Router builds that wrap the analytics component in a Suspense fallback to defer hydration. On classic Pages Router or on a layout with the analytics script outside the streaming tree, the listener binds once and the bug does not appear. Google's enhanced measurement docs recommend disabling individual triggers when you need to take manual control, which is the right escape hatch here.
The double-counting was getting worse on mobile because mobile pages were more likely to have a slow-resolving Suspense child for above-the-fold content, which is exactly the moment the analytics script gets re-evaluated.
The Fix
Disable the enhanced measurement scroll trigger in the GA4 admin, then emit one scroll event per pathname yourself from a client component that mounts exactly once at the root.
Step 1: Turn off the GA4 enhanced scroll trigger. In the GA4 admin, go to Admin > Data Streams > Web > your stream > Enhanced measurement > gear icon. Uncheck "Scrolls". Save. This stops the double-bound listener from emitting anything. Real-time debug view should now show zero scroll events from your site within five minutes.
Step 2: Emit scroll from a single mounted client component. Put this in your root layout, not inside a Suspense boundary:
// app/components/scroll-tracker.tsx
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useRef } from 'react'
declare global {
interface Window {
gtag?: (...args: unknown[]) => void
}
}
export function ScrollTracker() {
const pathname = usePathname()
const firedRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (firedRef.current.has(pathname)) return
function onScroll() {
const scrolled = window.scrollY + window.innerHeight
const total = document.documentElement.scrollHeight
if (total <= 0) return
if (scrolled / total < 0.9) return
if (firedRef.current.has(pathname)) return
firedRef.current.add(pathname)
window.gtag?.('event', 'scroll', {
percent_scrolled: 90,
page_path: pathname,
})
window.removeEventListener('scroll', onScroll)
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [pathname])
return null
}
Mount it in the root layout, outside any Suspense:
// app/layout.tsx
import { ScrollTracker } from './components/scroll-tracker'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<ScrollTracker />
</body>
</html>
)
}
The firedRef set is keyed by pathname, so soft navigations get a fresh chance to fire when the user scrolls a new page, but a single page can only fire once. The listener removes itself after firing, so there is no background work waiting after the user has already passed the threshold.
Step 3: Validate with the GA4 DebugView, not Real-time. Real-time aggregates events into one-minute buckets and will hide a duplicate that arrives in the same second. DebugView shows raw events with timestamps. To enable it without installing an extension, flip a flag in the layout:
<Script
id="gtag-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX', {
debug_mode: ${process.env.NODE_ENV !== 'production'},
send_page_view: false,
});
`,
}}
/>
Set send_page_view: false if you are already emitting page_view from a route-change effect. Otherwise GA4 will also double-count those, which is a separate bug I have written about in GA4 double counting on App Router.
Step 4: Backfill the data note in Looker Studio. GA4 cannot retroactively correct the duplicate scroll rows that already shipped to BigQuery. Add a calculated field that divides scroll event count by two for any date before the fix, and by one after. Otherwise the engagement KPI will appear to fall off a cliff on the day you shipped the fix, and someone on the marketing team will think the site got worse overnight.
The Lesson
GA4 enhanced measurement is convenient until the script binds twice. On App Router builds that wrap analytics under Suspense, that happens routinely, and the result is a doubled scroll metric that quietly corrupts content decisions. Disable the enhanced trigger, emit the event yourself from a single mounted client component, and validate with DebugView. The ratio drops back to a real number and the engagement KPI starts telling the truth.
If your analytics stack is reporting numbers that do not match reality, that is something I get hired to untangle. See my services. For another GA4-on-App-Router fix, read GA4 conversions server-side tagging.
GA4 metrics drifting from reality on your Next.js site? Get it fixed.