How Session Tracking Actually Works (Without Cookies)
A session groups individual events (page views, clicks) into a single "visit" using a 30-minute inactivity timeout. Traditional analytics use cookies, which require consent banners and get blocked by ITP. SingleAnalytics uses localStorage instead , it's client-side only, not sent with HTTP requests, and not subject to the ePrivacy cookie consent requirement. Sessions are derived from events, not the other way around.
Every analytics platform talks about "sessions", but what actually is a session? How does the software decide when one session ends and another begins? And how do you track sessions without using cookies?
This article is a deep technical dive into session management in web analytics, with a focus on the cookieless approach used by SingleAnalytics.
What Is a Session?
A session represents a single visit to your website. It starts when a user arrives and ends when they leave or become inactive. Sessions group individual events (page views, clicks, form submissions) into a coherent "visit" that you can analyze as a unit.
Sessions answer questions like:
- How long do people spend on my site?
- How many pages do they view per visit?
- What's the entry point and exit point of a typical visit?
- What percentage of visits are "bounces" (single page view)?
Traditional Session Tracking (Cookies)
The traditional approach uses cookies:
- User arrives → Server sets a session cookie (e.g.,
_ga_session=abc123; expires=30m) - User navigates → Cookie is sent with every request, linking events to the session
- User is inactive for 30 minutes → Cookie expires
- User returns → New cookie, new session
Problems with this approach:
- Requires cookie consent in the EU (ePrivacy Directive)
- Blocked by browsers' ITP (Intelligent Tracking Prevention)
- Cleared when users delete cookies
- Third-party cookie blocking breaks cross-domain tracking
- Safari limits first-party cookies set via JavaScript to 7 days
Cookieless Session Tracking
SingleAnalytics uses localStorage instead of cookies. Here's exactly how it works:
Session Initialization
When the SDK loads on a page, it checks localStorage for an existing session:
// Pseudocode for session management
function getOrCreateSession() {
const stored = localStorage.getItem('sa_session');
if (stored) {
const session = JSON.parse(stored);
const lastActivity = new Date(session.lastActivity);
const now = new Date();
const minutesInactive = (now - lastActivity) / 1000 / 60;
if (minutesInactive < 30) {
// Session is still active , update last activity
session.lastActivity = now.toISOString();
localStorage.setItem('sa_session', JSON.stringify(session));
return session.id;
}
}
// No session or session expired , create a new one
const newSession = {
id: generateId(),
startedAt: new Date().toISOString(),
lastActivity: new Date().toISOString()
};
localStorage.setItem('sa_session', JSON.stringify(newSession));
return newSession.id;
}
The 30-Minute Rule
The industry-standard session timeout is 30 minutes of inactivity. Here's why:
- Too short (5 min): A user reading a long article would start a new session when they navigate to the next page
- Too long (2 hours): A user who leaves for lunch and comes back would be counted as the same session, inflating session duration
- 30 minutes: Balances accuracy for both content sites (long reading sessions) and interactive apps (rapid navigation)
Why localStorage, Not Cookies?
| Aspect | Cookies | localStorage | |---|---|---| | Sent to server | Yes, with every request | No, client-side only | | Consent required | Yes (ePrivacy) | Not for functional use | | Storage limit | ~4KB per cookie | ~5-10MB | | Expiration | Can be set | Persistent until cleared | | Cross-domain | With configuration | Same-origin only | | Blocked by ITP | Increasingly | No |
The critical difference: cookies are sent to the server with every HTTP request, which is what makes them "tracking technology" under the ePrivacy Directive. localStorage is purely client-side storage: the browser never automatically sends it to any server. The SDK reads the session ID and explicitly includes it in analytics events.
User ID Persistence
In addition to sessions, the SDK maintains a persistent anonymous user ID:
function getOrCreateUserId() {
let userId = localStorage.getItem('sa_user_id');
if (!userId) {
userId = 'anon_' + generateId();
localStorage.setItem('sa_user_id', userId);
}
return userId;
}
This anonymous ID persists across sessions, allowing you to track returning visitors. When sa.identify() is called, this anonymous ID is linked to a known user identity.
Session Properties
Each session tracks several derived properties:
Duration
Calculated as the time between the first and last event in the session:
duration = lastEvent.timestamp - firstEvent.timestamp
Note: If a session has only one event (a bounce), the duration is 0. This is standard across all analytics platforms.
Page Views
Count of page_view events within the session.
Bounce Rate
A session is a "bounce" if it contains only one page view. The bounce rate is:
bounceRate = singlePageViewSessions / totalSessions
Landing Page and Exit Page
- Landing page: The path of the first
page_viewevent in the session - Exit page: The path of the last
page_viewevent in the session
Traffic Source
Attribution is determined from the first event in the session:
if (utmParams present) → use UTM source/medium
else if (referrer present) → classify referrer domain
else → direct / none
Edge Cases and How We Handle Them
User Opens Multiple Tabs
The session ID is shared across all tabs via localStorage. Events from all tabs use the same session ID and update the same lastActivity timestamp. This is correct behavior: the user is in one "visit" even if they have multiple tabs open.
User Closes Browser and Returns
If the user closes the browser and returns within 30 minutes, the session continues (localStorage persists across browser restarts). If they return after 30 minutes, a new session starts.
User Clears localStorage
A new anonymous ID and session are created. This is equivalent to a brand-new visitor. There's no way to link them to their previous identity (unless they log in again and identify() is called).
Private/Incognito Mode
localStorage works in incognito mode but is cleared when the incognito window is closed. Each incognito session starts fresh.
Server-Side Session Enrichment
When events arrive at the server, the session document is created or updated:
- First event in a session → Create session document with traffic source, landing page, device info
- Subsequent events → Update
lastActivityAt, increment event/pageview counts, update exit page - Session timeout (no events for 30 min) → Mark session as inactive, calculate final duration
Debugging Sessions
If session data looks off, here are common causes:
Sessions are too short: Check if your SPA navigation is triggering page reloads (which reset the SDK). SingleAnalytics hooks into pushState and popstate to handle SPA navigation without reloads.
Sessions are too long: Make sure you're not artificially keeping sessions alive with background polling or heartbeat requests that include the session ID.
Too many sessions per user: Check if localStorage is being cleared by your application or by a privacy extension. Also check for service workers that might be interfering.
User counts don't match: Remember that the anonymous user ID is per-browser, per-device. The same person using Chrome on their laptop and Safari on their phone counts as two users until they identify() on both.
Conclusion
Session tracking is one of those things that seems simple on the surface but has significant complexity underneath. By using localStorage instead of cookies, you get accurate session tracking without the privacy and consent complications of cookie-based approaches.
The key takeaway: sessions are derived from events, not the other way around. Track events accurately, and sessions take care of themselves.
Want to see your session data in real-time? Try SingleAnalytics . events appear in your dashboard within seconds.