How We Calculate Bounce Rate and Session Duration — Our Methodology, in the Open
Bounce rate and session duration are among the most-cited metrics in analytics — and among the most poorly understood. We show the exact SQL behind them, what “session” means for us, and why a bounce always has a duration of 0.
Bounce rate and average session duration are two of the most-cited metrics on an analytics dashboard — and, in most tools, two of the least explained. Every tool calculates them slightly differently, and the differences matter when you compare numbers across tools. This article describes exactly the formula we use, with nothing hidden.
What a “session” actually is
A session is a continuous group of events (pageviews and custom events) from the same visitor on the same site. Grouping happens server-side, on every event received: we look up that visitor's most recent session; if its last event happened less than 30 minutes ago, the new event joins that same session; otherwise we start a new one. That's the industry-standard 30-minute inactivity window (the same value Google Analytics uses).
There's no session cookie — the grouping is calculated from the timestamps of events already stored, not from a session identifier kept on the visitor's device.
The bounce rate formula
A bounce is a session with exactly one pageview — the visitor landed on a page and didn't navigate anywhere else on the site before the session closed. The query, simplified down to its essence, looks like this:
SELECT "sessionId", COUNT(*) FILTER (WHERE type = 'pageview') AS total_pageviews FROM "Event" WHERE "siteId" = $1 AND "createdAt" >= $2 GROUP BY "sessionId" -- bounce_rate = sessions where total_pageviews = 1, divided by total sessions
— src/lib/queries.ts
We count the pageviews in each session. A session with total_pageviews = 1 is a bounce. The percentage shown is (sessions with exactly one pageview) / (total sessions) × 100, rounded to a whole number. One detail that matters: custom events (a button click, a scroll, an outbound_click) don't count as a pageview — so a session with one pageview and three custom events is still counted as a bounce under the classic definition (based on navigation between pages), even though the visitor clearly engaged with the page. That's a known limitation of the classic bounce rate definition, not just of our implementation — any tool that defines a bounce as “a single page viewed” has the same nuance.
The session duration formula
A session's duration is calculated as the difference, in seconds, between the timestamp of the first and last event in that session:
SELECT "sessionId",
EXTRACT(EPOCH FROM (MAX("createdAt") - MIN("createdAt"))) AS duration
FROM "Event"
WHERE "siteId" = $1 AND "createdAt" >= $2
GROUP BY "sessionId"— src/lib/queries.ts
The average shown on the dashboard is the arithmetic mean of these durations across all sessions in the selected period.
Why a bounce always has a duration of 0
This is the most important nuance, and the most commonly misunderstood one in any analytics tool without an active “heartbeat” on the page: if a session has a single event, MAX(createdAt) and MIN(createdAt) are the same timestamp, so the calculated duration is exactly 0 seconds — regardless of whether the visitor spent 3 seconds on the page or 8 minutes reading the entire article before leaving.
The reason is simple: the only signal we have about “how long” is the difference between two recorded events. Without a second event (a second pageview, or a custom event triggered manually, for example a scroll_to_pricing or a click tracked with window.analytics()), there's no second timestamp to calculate a duration from. This isn't specific to our implementation — any analytics tool that doesn't periodically send a browser heartbeat “ping” (most don't, because it means extra network traffic and complexity) has exactly the same limitation for single-page sessions.
What you can do if you want real duration even for bounces
If you want to measure time spent on a page even when the visitor doesn't navigate further, send a custom event at a fixed interval (for example, 15 seconds after load) using window.analytics("engaged_15s"). That adds a second Event row to that session, so EXTRACT(EPOCH FROM (MAX-MIN)) is no longer 0 — but it also increases the number of requests sent by the tracker, a trade-off we leave up to you rather than enabling by default.
What this means in practice for interpreting the numbers
- A high bounce rate on a blog page isn't necessarily bad — it might just mean the visitor read the article and left satisfied, without navigating further. Don't read it in isolation; look at it alongside session duration (where available from other multi-page sessions starting on the same page) and conversions.
- The average session duration shown is always an underestimate of the actual time spent on the site, because every single-page session contributes 0 to the average — the more bounces you have, the more artificially the average gets pulled down.
- When comparing against another analytics tool: if that tool uses a session window other than 30 minutes, or counts events differently (some include automatic scrolling as “engagement”), the numbers won't match exactly — that doesn't mean one of them is “wrong,” just that the methodology differs. That's why we publish our exact formula, so you know what you're comparing.