Tracking5 min read
Single-page apps
Mrkr detects client-side route changes automatically for essentially every History-API router. One signal, Web Vitals, is a load-time measurement by nature and doesn't restart on a virtual page change: that's a known limitation, not a bug.
What works automatically
The tracker patches both history.pushState and history.replaceState, and listens for popstate, so a route change made through any of the three fires a fresh pageview with no code changes on your part. Patching replaceState matters more than it sounds: Next.js App Router, router.replace(), React Router's { replace: true }, and every query-param state library navigate that way. A change that leaves the path and query string identical is ignored, so a router that replaces state without moving doesn't produce a duplicate pageview. Along with the new pageview:
- Scroll-depth tracking resets for the new page: thresholds are re-measured against the new document, not carried over from the page you left. The measurement is deferred a tick so your framework has committed the new DOM first, rather than measuring the outgoing page's height.
- Duration, visible time, and engaged time are measured per route: the outgoing route's totals are closed out and flushed as its final
page_leavebefore the new route's timers start, so each route gets its own numbers rather than the last one absorbing the whole visit. - Custom events and everything else work exactly as they do on a traditional multi-page site: none of it depends on a full page load.
- Back/forward through the bfcache fires a pageview too. A restored page would otherwise produce no pageview at all, which inflates bounce rate and deflates pages per session. A bfcache restore continues the existing session rather than starting a new one.
What doesn't reset automatically
One signal is tied to the page's actual load event, not to virtual navigation, and the tracker doesn't restart it on pushState:
- Web Vitals (LCP, CLS, INP, FCP, TTFB) are load-time measurements: they're captured once for the real page load and reported when the tab is hidden or closed, but they don't re-measure for each client-side route. In practice this is usually fine: these metrics describe how the page performed loading in the first place, which a virtual route change doesn't repeat anyway. It does mean a long-lived SPA session reports one set of vitals for the entry route, not one per screen.
It fires the same logic the automatic pushState hook does: it closes out the previous route's timing, starts fresh timers, and re-measures scroll geometry. It does not re-measure Web Vitals, because nothing on the client can.
Routers that don't use the History API
Detection is hooked to pushState, replaceState, and popstate. A router that navigates purely by changing the URL fragment (an old-style #/route hash router) does not go through any of them, so it won't trigger an automatic pageview: listen for hashchange yourself and call mrkr.pageview() on navigation in that case.