Every interesting product decision begins with the same question: What are people actually doing on my site? Which buttons are 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 without needing a heavy SDK.
In this guide, we will create 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 surely (this is the part where most tutorials get it wrong); and how to do it all without compromising performance or trampling on user privacy.
In the end, you’ll understand exactly how the analysis tools you’ve used really work and you’ll have a starting point that you can expand upon as you wish.
What does “track user behavior” really mean?
When people say “track user behavior,” they usually mean two different types of data.
Quantitative data tells you that happened: how many page views, how many clicks, the bounce rate, the conversion rate. It is accountant and excellent at spotting trends.
Behavioral (or qualitative) data tells you because It happened: a user furiously clicks on an unresponsive button, abandons a form in the third field, or scrolls past your call to action without stopping. This is where useful information is usually hidden.
A good tracking setup captures a bit of both. The basic components are almost always the same handful of signals:
- Page views – what pages are loaded and the referrer that brought the visitor.
- Clicks – in links, buttons and other interactive elements.
- Travel depth – how far people go down the page.
- time on page – how long someone is actually engaged (not just idle in a background tab).
- Form interactions – which fields receive attention and where users abandon.
- Custom events — anything specific to your app: “added to cart,” “started trial,” “played video.”
Let’s capture each of these in turn.
Listen to events the right way
The naive approach is to attach an event listener to each element you are interested in. That works until your DOM changes: new elements added by a framework or an AJAX update will not have listeners, and attaching hundreds of individual listeners is a waste.
The best pattern is event delegation– Attaches a single listener to a parent (often document) and inspect the objective of the event. Because most DOM events are raised, a 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 = null,
;
trackEvent(payload);
});
He data-track The attribute is a small but powerful convention: it allows you to mark exactly the elements worth measuring () without encoding selectors in your script. we will define trackEvent() shortly.
Tracking element visibility with IntersectionObserver
Sometimes you don’t care about the clicks, but if someone saw something at all. Has the price table ever entered the viewport? Did anyone get to the footer CTA?
The polling offset position for this is inefficient and wacky. The modern tool is the IntersectionObserver API, which notifies you asynchronously when an element enters or leaves the viewport, without scroll event spam or layout changes.
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);
});
Capture scroll depth and time on page
Scroll depth is one of the most useful engagement signals and you can get it without listening to every scroll tick. Accelerate the controller to calculate 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 mentioning: the { passive: true } The option tells the browser that we will not call. preventDefault()allowing you to continue moving smoothly, and requestAnimationFrame It bundles our work into the browser’s paint cycle instead of firing on every moving pixel.
time on page It sounds simple but it has a classic catch: if you only record the interval between page loading and unloading, you will count the time the user spent in another tab with your page frozen in the background. Use the Page Visibility API just count asset 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 altering the UX
This is where most tutorials go wrong, so it’s worth stopping.
You have collected events; now you need to send them to your backend. The instinct is to shoot a fetch() either XMLHttpRequest when the user leaves the page. The problem: As soon as the page starts downloading, the browser can cancel ongoing requests. Your “abandoned user” event, the most important one, is the one most likely to be discarded. The older guides “solved” this with a synchronous XHR, which crashes the browser and creates exactly the kind of junk that makes pages look broken, and is now deprecated.
The correct tool is navigator.sendBeacon(). Queues a small POST request that the browser guarantees to send even after the page disappearsasynchronously 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 blush. Don’t trust the old unload event: Unreliable on mobile devices, where users switch apps instead of closing tabs. The recommended trigger is visibilitychange shooting 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 batch-plus-beacon pattern is exactly how production analysis scripts behave: they store events in memory and flush them in efficient bursts, with a final flush guaranteed on exit.
Structure a clean event payload
A little discipline in your event outline pays off enormously when you later try to query the data. Aim for a flat, consistent way where each 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 temptation to nest deeply, but analytical queries are much easier with flat data. On the server, you would validate this payload and write it to whatever store fits your scale, from a single Postgres table for small projects to a columnstore for high volume.
Respect privacy and consent
Behavior tracking 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 are increasingly blocking scripts that appear invasive.
Some principles keep you on solid ground. By default, it does not collect personally identifiable information; you almost never need names, emails, or precise IP addresses to understand behavior. If you require consent for cookies, please request consent before setting them and consider a proprietary and cookie-free approach where you get an anonymous session identifier in memory instead of keeping a tracking cookie. Be transparent in your privacy policy about what you log and respect browser recommendations. Do Not Track and Global Privacy Control signs when present.
The script we created already has a privacy-friendly ethos: it captures interactions, not identities.
Don’t let tracking slow down your site
Analytics that hurt performance are counterproductive: slower pages mean worse engagement, which corrupts the very numbers you’re collecting. Keep your tracking light.
Load the script asynchronously (or postpone it) so that it never blocks rendering and keep it out of the critical path of your Basic Web Vitals. Batch network requests instead of sending one per event. Use passive event detectors and requestAnimationFramelike we did above, so that the controllers don’t block scrolling or input. And keep the payload small: a few kilobytes of JSON, not a screenshot of the DOM. If done correctly, behavior tracking should be invisible to the user and have no measurable impact on loading time.
All of the above is really useful, and for a personal project or a lightweight internal dashboard, a script of your own like this is usually all you need.
But once you need session funnels, retention cohorts, user segmentation, and a polished reporting UI, creating and maintaining them yourself becomes a project in itself, at which point you create a dedicated web and product analytics platform like Nice ideas It will save you considerable time. The good news is that the concepts you’ve learned here relate directly to how those tools work, so you’ll know exactly what’s going on behind your dashboards.
Frequently asked questions
How do websites track user activity? They run a small piece of JavaScript that listens for events (clicks, scrolls, page views), groups them in memory, and sends them to a server, usually via navigator.sendBeacon() so the data survives the user who leaves the page. The server stores the events for later analysis.
What is the difference between event-based and page-based tracking? Page-based tracking records discrete page views, which is useful for traffic volume and navigation paths. Event-based tracking records specific interactions inside a page (a click, a video playing, a form submission), which is essential for single-page applications 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, group your network requests, and rely on sendBeacon for outing events. A well-built crawler 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, tag, timestamp, and session ID is sufficient. At higher volumes, a column store or time series store handles analytical queries more efficiently. Start simple and migrate when query performance requires.
Is custom tracking GDPR compliant? Could be. Don’t collect PII by default, be transparent in your privacy policy, obtain consent before setting cookies, and prefer anonymous, first-party data. The lighter your data collection, the simpler your compliance story will be.
Concluding
Now you’ve seen the full anatomy of behavior tracking in the browser: delegated event listeners for clicks, IntersectionObserver for visibility, accelerated scroll depth milestones, visibility-aware time tracking and, the crucial piece, reliable delivery with navigator.sendBeacon() in visibilitychange. Wrap those signals in a clean, flat event schema, take privacy and performance into account, and you’ll have a tracking layer that you completely understand and control.
From here you can extend it as your product requires: add form field analytics, capture angry clicks, define custom conversion events, or funnel everything into a display layer. The fundamentals don’t change, and now you know them from scratch.





