Skip to content

Pageviews & SPAs

Unless data-auto="false" is set, the tracker sends one pageview as soon as it can: immediately if the document is already parsed, otherwise on DOMContentLoaded.

It is held — along with anything you queued — until the identity probe answers or 1500 ms elapse, whichever is first. That wait is what keeps the landing hit, and therefore the campaign that produced the visit, filed under the same visitor as everything after it.

The tracker patches history.pushState and listens for popstate, so any router that navigates through the History API is tracked with no extra code. That covers:

  • Next.js (App Router and Pages Router)
  • React Router, TanStack Router, Wouter
  • Vue Router, Nuxt
  • SvelteKit
  • Astro View Transitions
  • Angular Router

Hash-only navigation does not touch the History API. Opt in:

<script defer
data-site="pk_live_xxxxxxxx"
data-hash="true"
src="https://ingest.tracing.tools/t.js"></script>

With data-hash="true" the tracker also listens for hashchange and includes the hash when deciding whether the path changed.

Only pushState and popstate trigger one. Replacing the URL to keep a filter or a scroll position in sync — which is what replaceState is for — correctly does not count as a new page.

A pageview is skipped when the computed path is identical to the last one sent:

location.pathname + location.search + (data-hash ? location.hash : "")

So a router that fires twice for one navigation produces one pageview, and pushState to the same URL produces none.

Note that the query string is part of that comparison, even though the dashboard groups by path. /search?q=a then /search?q=b are two pageviews that both appear under /search.

tracing.pageview();

Useful when you set data-auto="false" because you need to wait for something — a consent decision, a locale redirect, an A/B assignment — before the first hit. Deduplication still applies, so calling it twice for the same URL sends one hit.

Stored as From
Path location.pathname, / if empty
Query location.search with utm_*, ref, source, via, fbclid, gclid, gbraid, wbraid, msclkid and ttclid removed; null if nothing is left
Title document.title
Hostname the host of the URL
Referrer document.referrer, plus a bare referrer domain with www. stripped, null when it is your own host
UTM utm_source/medium/campaign/term/content, with ref, source or via as a fallback for utm_source
Language navigator.language
Screen window.innerWidth × innerHeight
Browser, version, OS, device parsed from the user agent server-side
Country, region, city resolved server-side — see Geo

Splitting the query off the path is what keeps /pricing?utm_source=hn and /pricing the same row on the dashboard, while /search?q=cookies keeps its term for the Query dimension.

Two mechanisms fill in how long a page was looked at.

On the next pageview. The tracker measures the gap since the last pageview and sends it as d on the new hit. That figure lands on the session total.

On page hide. When visibilitychange fires with visibilityState === "hidden", the tracker sends a duration hit by sendBeacon with the elapsed time. Coming back to the foreground resets the clock, so time spent in a background tab is not counted, and an elapsed time under one second is not sent at all.

A flushed duration is clamped to between 0 and 30 minutes, the same idle window that ends a visit.

A visit is a bounce while it has one or fewer pageviews. It stops being one the moment a second pageview arrives. Custom events, outbound clicks and revenue events do not clear the bounce flag — which is deliberate: a single-page visit where somebody converted is still a single-page visit, and Sessions has a Converted chip for exactly that case.