Every interesting product decision starts with the same question: what are people actually doing on my site? Which buttons get clicked, how far visitors scroll, where they hesitate, and where they quietly leave. You can answer all of this with a few well-placed lines of JavaScript and no heavyweight SDK required.
In this guide, we’ll build a small, dependency-free behavior-tracking layer from scratch. You’ll learn how to capture clicks, scroll depth, time on page, and custom events; how to send that data to your server reliably (this is the part most tutorials get wrong); and how to do it all without hurting performance or trampling user privacy.
By the end, you’ll understand exactly how the analytics tools you’ve used actually work under the hood and you’ll have a starting point you can extend however you like.
What “tracking user behavior” actually means
When people say “track user behavior,” they’re usually talking about two different kinds of data.
Quantitative data tells you what happened: how many pageviews, how many clicks, the bounce rate, the conversion rate. It’s countable and great for spotting trends.
Behavioral (or qualitative) data tells you why it happened: a user rage-clicking a button that isn’t responding, abandoning a form at the third field, or scrolling past your call-to-action without pausing. This is where the actionable insight usually hides.
A good tracking setup captures a bit of both. The building blocks are almost always the same handful of signals:
- Pageviews — which pages get loaded, and the referrer that brought the visitor.
- Clicks — on links, buttons, and other interactive elements.
- Scroll depth — how far down the page people actually get.
- Time on page — how long someone is genuinely engaged (not just idling in a background tab).
- Form interactions — which fields get focus, and where users drop off.
- Custom events — anything specific to your app: “added to cart,” “started trial,” “played video.”
Let’s capture each of these in turn.
Listening for events the right way
The naive approach is to attach an event listener to every element you care about. That works until your DOM changes — new elements added by a framework or an AJAX update won’t have listeners, and attaching hundreds of individual listeners is wasteful.
The better pattern is event delegation: attach a single listener to a parent (often document) and inspect the event target. Because most DOM events bubble up, one listener can handle clicks on elements that didn’t even exist when the page loaded.
javascript
document.addEventListener('click', (event) => {
// Find the nearest meaningful element, even if the user
// clicked an icon inside a button.
const target = event.target.closest('a, button, [data-track]');
if (!target) return;
const payload = {
type: 'click',
tag: target.tagName.toLowerCase(),
text: target.textContent.trim().slice(0, 100),
id: target.id || null,
// Let authors opt specific elements into richer tracking.
label: target.dataset.track || null,
href: target.getAttribute('href') || null,
};
trackEvent(payload);
});
The data-track attribute is a small but powerful convention: it lets you mark exactly the elements worth measuring () without hardcoding selectors in your script. We’ll define trackEvent() shortly.
Tracking element visibility with IntersectionObserver
Sometimes you don’t care about clicks — you care about whether someone saw something at all. Did the pricing table ever enter the viewport? Did anyone reach the footer CTA?
Polling scroll position for this is inefficient and janky. The modern tool is the IntersectionObserver API, which asynchronously notifies you when an element enters or leaves the viewport, no scroll-event spam, no layout thrashing.
javascript
const visibilityObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
trackEvent({
type: 'visible',
label: entry.target.dataset.trackView,
});
// Only report the first time it becomes visible.
visibilityObserver.unobserve(entry.target);
}
});
}, { threshold: 0.5 }); // Fire when 50% of the element is in view.
document.querySelectorAll('[data-track-view]').forEach((el) => {
visibilityObserver.observe(el);
});
Capturing scroll depth and time on page
Scroll depth is one of the most useful engagement signals, and you can derive it without listening to every scroll tick. Throttle the handler so you compute position at most a few times per second, then report milestone thresholds (25%, 50%, 75%, 100%) once each.
javascript
const milestones = [25, 50, 75, 100];
const reached = new Set();
let ticking = false;
function checkScrollDepth() {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const percent = docHeight > 0 ? Math.round((scrollTop / docHeight) * 100) : 100;
milestones.forEach((milestone) => {
if (percent >= milestone && !reached.has(milestone)) {
reached.add(milestone);
trackEvent({ type: 'scroll_depth', value: milestone });
}
});
ticking = false;
}
window.addEventListener('scroll', () => {
// requestAnimationFrame keeps us off the main thread's critical path.
if (!ticking) {
window.requestAnimationFrame(checkScrollDepth);
ticking = true;
}
}, { passive: true });
Two details worth noting: the { passive: true } option tells the browser we won’t call preventDefault(), which lets it keep scrolling smooth, and requestAnimationFrame batches our work to the browser’s paint cycle instead of firing on every pixel of movement.
Time on page sounds simple but has a classic trap: if you just record the gap between page load and unload, you’ll count time the user spent in another tab with your page frozen in the background. Use the Page Visibility API to only count active time.
javascript
let activeTime = 0;
let lastResume = Date.now();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
activeTime += Date.now() - lastResume; // Bank the active stretch.
} else {
lastResume = Date.now(); // Resume the clock.
}
});
function getEngagedTime() {
const current = document.visibilityState === 'visible'
? Date.now() - lastResume
: 0;
return Math.round((activeTime + current) / 1000); // seconds
}
Sending data to your server without breaking UX
Here’s where most tutorials go wrong, so it’s worth slowing down.
You’ve collected events — now you need to get them to your backend. The instinct is to fire a fetch() or XMLHttpRequest when the user leaves the page. The problem: as soon as the page starts unloading, the browser is free to cancel in-flight requests. Your “user left” event, the single most important one, is the most likely to be dropped. Older guides “solved” this with a synchronous XHR, which blocks the browser and creates exactly the kind of junk that makes pages feel broken , and it’s now deprecated.
The correct tool is navigator.sendBeacon(). It queues a small POST request that the browser guarantees to send even after the page is gone, asynchronously and without blocking navigation.
javascript
function sendBeacon(events) {
const url = '/api/collect';
const body = JSON.stringify({ events, url: location.pathname });
// sendBeacon is ideal for fire-and-forget telemetry on exit.
if (navigator.sendBeacon) {
const blob = new Blob([body], { type: 'application/json' });
navigator.sendBeacon(url, blob);
} else {
// Fallback for older browsers: keepalive lets fetch outlive the page.
fetch(url, { method: 'POST', body, keepalive: true });
}
}
Equally important is when you flush. Don’t rely on the old unload event — it’s unreliable on mobile, where users switch apps rather than closing tabs. The recommended trigger is visibilitychange firing with a hidden state, with pagehide as a backup.
javascript
let queue = [];
function trackEvent(event) {
queue.push({ ...event, t: Date.now() });
// Batch sends so we're not hammering the server on every click.
if (queue.length >= 10) flush();
}
function flush() {
if (queue.length === 0) return;
sendBeacon(queue);
queue = [];
}
// Flush whenever the page is being backgrounded or closed.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
window.addEventListener('pagehide', flush);
This batching-plus-beacon pattern is exactly how production analytics scripts behave: they buffer events in memory and flush them in efficient bursts, with a guaranteed final flush on exit.
Structuring a clean event payload
A little discipline in your event schema pays off enormously when you later try to query the data. Aim for a flat, consistent shape where every event shares a common envelope and carries its own type-specific fields.
javascript
{
"events": [
{
"type": "click",
"label": "signup-cta",
"t": 1716200000000
},
{
"type": "scroll_depth",
"value": 75,
"t": 1716200005000
}
],
"url": "/pricing",
"referrer": "https://www.google.com/",
"sessionId": "a1b2c3d4",
"screen": { "w": 1440, "h": 900 }
}
Keep names short and stable, store timestamps as epoch milliseconds, and resist the urge to nest deeply but analytics queries are far easier against flat data. On the server you’d validate this payload and write it to whatever store fits your scale, from a single Postgres table for small projects to a columnar warehouse for high volume.
Respecting privacy and consent
Tracking behavior is not a license to collect everything. Beyond being the right thing to do, regulations like the GDPR and CCPA carry real penalties, and users increasingly block scripts that feel invasive.
A few principles keep you on solid ground. Collect no personally identifiable information by default, you almost never need names, emails, or precise IP addresses to understand behavior. If you need consent for cookies, request it before setting them, and consider a cookieless, first-party approach where you derive an anonymous session identifier in memory rather than persisting a tracking cookie at all. Be transparent in your privacy policy about what you record, and honor the browser’s Do Not Track and Global Privacy Control signals where present.
The script we built is already privacy-friendly in spirit: it captures interactions, not identities.
Don’t let tracking slow down your site
Analytics that hurt performance are self-defeating — slower pages mean worse engagement, which corrupts the very numbers you’re collecting. Keep your tracking lightweight.
Load the script asynchronously (or defer it) so it never blocks rendering, and keep it out of the critical path of your Core Web Vitals. Batch network requests instead of sending one per event. Use passive event listeners and requestAnimationFrame, as we did above, so handlers don’t block scrolling or input. And keep the payload small — a few kilobytes of JSON, not a screenshot of the DOM. Done right, behavior tracking should be invisible to the user and have no measurable impact on load time.
Everything above is genuinely useful, and for a personal project or a lightweight internal dashboard, a homegrown script like this is often all you need.
But once you need session funnels, retention cohorts, user segmentation, and a polished reporting UI, building and maintaining that yourself becomes a project in its own right at which point a dedicated web and product analytics platform such as Pretty Insights will save you considerable time. The good news is that the concepts you’ve learned here map directly onto how those tools work, so you’ll know exactly what’s happening behind their dashboards.
Frequently asked questions
How do websites track user activity? They run a small JavaScript snippet that listens for events (clicks, scrolls, pageviews), batches them in memory, and sends them to a server — typically via navigator.sendBeacon() so the data survives the user leaving the page. The server stores the events for later analysis.
What’s the difference between event-based and page-based tracking? Page-based tracking records discrete pageviews — useful for traffic volume and navigation paths. Event-based tracking records specific interactions within a page (a click, a video play, a form submit), which is essential for single-page apps where the URL may not change as users interact.
Does adding tracking slow down my page? It shouldn’t, if you do it right. Load the script asynchronously, use passive listeners, batch your network requests, and rely on sendBeacon for exit events. A well-built tracker has no measurable effect on Core Web Vitals.
How should I store the tracked data? For small projects, a single relational table (Postgres, MySQL) with columns for event type, label, timestamp, and session ID is plenty. At higher volumes, a columnar or time-series store handles analytical queries more efficiently. Start simple and migrate when query performance demands it.
Is custom tracking GDPR-compliant? It can be. Collect no PII by default, be transparent in your privacy policy, obtain consent before setting any cookies, and prefer anonymous, first-party data. The lighter your data collection, the simpler your compliance story.
Wrapping up
You’ve now seen the full anatomy of behavior tracking in the browser: delegated event listeners for clicks, IntersectionObserver for visibility, throttled scroll-depth milestones, visibility-aware time tracking, and — the crucial piece — reliable delivery with navigator.sendBeacon() on visibilitychange. Wrap those signals in a clean, flat event schema, keep privacy and performance front of mind, and you have a tracking layer you fully understand and control.
From here you can extend it however your product demands: add form-field analytics, capture rage clicks, define custom conversion events, or pipe everything into a visualization layer. The fundamentals don’t change — and now you know them from the ground up.

