You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
We researched this with three independent agents (web research, design and prototyping, device testing), cross-reviewed the results, and tested the current script next to a prototype on real BrowserStack devices: iOS 18, 26 and 27 Safari, Chrome on iOS, Android Chrome (Pixel and Galaxy), Samsung Internet, desktop Safari, Edge, Firefox 157 and Firefox ESR 140.
Why durations go missing today
Pings alone wouldn't fix this. The script loses data because of how it sends:
It only sends on pagehide. Home, app switching and screen locking on mobile fire only visibilitychange → hidden. If the OS then kills the browser, nothing is ever sent.
It sends increments, and the queue keeps the last value it processes. After a send the counters reset. Every real browser we tested restored pages from the back/forward cache, so the second send overwrites the first (for example 6 s becomes 4 s). Updates can also be processed out of order.
Firefox tab close: Firefox stops the page's first pagehide listener while it is inside sendBeacon. When our script is that listener, only 1 of 12 tab closes delivered a duration.
Smaller bugs:
Hidden time is counted when the page closes while it is hidden.
Pages opened in a background tab send NaN, which the queue stores as null over an existing duration.
SPA page views inherit the previous page's scroll depth.
Manual sa_pageview page views never get their own duration.
light.js sends an empty append on every page view.
Real-device results (current script vs prototype on the same page)
Scenario
Current
Prototype
Home or background, then the browser is killed
0/6
6/6
Screen lock, then the browser is killed
0/5
5/5
data-pings="3,60", browser killed at about 70 s while visible
0/6
6/6 (3 and 60)
Back/forward cache round trip
second value is an increment
cumulative
Firefox tab close
1/12
12/12
Proposed fix
Queue: only ever raise duration_seconds and scrolled_percentage, using an update script with retry_on_conflict. Updates then can't lower stored values, whatever order they arrive in. This has to ship before the script change.
Script:
Count visible time only, rounded down, so a stored value is a lower bound.
Send the running total when the page becomes hidden, with pagehide as a fallback.
Skip an identical second send only after sendBeacon returned true.
Keep the page ID across back/forward cache restores.
Let every page view, including manual ones, close the previous one.
No unload, no heartbeat interval, no storage.
data-pings: thresholds in visible seconds, each sending the running total. A ping value means "visible for at least N seconds", not the full visit. It's the only data we get when a page is killed while visible, or when it's closed from the iOS tab switcher, where WebKit fires no event at all (WebKit 205942). We suggest shipping it opt-in first and measuring the lifecycle fix alone. After that, 5 is a better default than 3: the dashboard median already excludes durations under 5 seconds (gte: 5), so a stored 3 is treated as a bounce. 5,60 works well for long-read sites.
Request cost
Append requests per page view, from a model with assumed visit lengths, not production data:
Option
Desktop
Mobile
Today
1.02
0.53
Lifecycle fix
1.30
1.39
+ pings 5
2.08
2.08
+ pings 5,60
2.35
2.26
+ pings 3,60
2.41
2.32
Heartbeat every 15 s
4.38
3.35
On mobile, most of the extra requests carry data that is lost today.
Not covered
The iOS tab-switcher close can't be automated on BrowserStack.
In-app browsers weren't tested, and Firefox Android isn't available.
Firefox ESR 140 loses everything when the last window closes (bug 1931956).
fetchLater could later replace pings in Chromium browsers as an experiment.
Activity
simpleanalytics-agent commentedon Oct 19, 2026
Research: reliable time on page
We researched this with three independent agents (web research, design and prototyping, device testing), cross-reviewed the results, and tested the current script next to a prototype on real BrowserStack devices: iOS 18, 26 and 27 Safari, Chrome on iOS, Android Chrome (Pixel and Galaxy), Samsung Internet, desktop Safari, Edge, Firefox 157 and Firefox ESR 140.
Why durations go missing today
Pings alone wouldn't fix this. The script loses data because of how it sends:
pagehide. Home, app switching and screen locking on mobile fire onlyvisibilitychange→ hidden. If the OS then kills the browser, nothing is ever sent.pagehidelistener while it is insidesendBeacon. When our script is that listener, only 1 of 12 tab closes delivered a duration.Smaller bugs:
NaN, which the queue stores asnullover an existing duration.sa_pageviewpage views never get their own duration.light.jssends an empty append on every page view.Real-device results (current script vs prototype on the same page)
data-pings="3,60", browser killed at about 70 s while visibleProposed fix
Queue: only ever raise
duration_secondsandscrolled_percentage, using an update script withretry_on_conflict. Updates then can't lower stored values, whatever order they arrive in. This has to ship before the script change.Script:
pagehideas a fallback.sendBeaconreturnedtrue.unload, no heartbeat interval, no storage.data-pings: thresholds in visible seconds, each sending the running total. A ping value means "visible for at least N seconds", not the full visit. It's the only data we get when a page is killed while visible, or when it's closed from the iOS tab switcher, where WebKit fires no event at all (WebKit 205942). We suggest shipping it opt-in first and measuring the lifecycle fix alone. After that,5is a better default than3: the dashboard median already excludes durations under 5 seconds (gte: 5), so a stored 3 is treated as a bounce.5,60works well for long-read sites.Request cost
Append requests per page view, from a model with assumed visit lengths, not production data:
55,603,60On mobile, most of the extra requests carry data that is lost today.
Not covered
fetchLatercould later replace pings in Chromium browsers as an experiment.