Server-side vs client-side analytics: what each one can see
Not a reliability contest. Server-side overcounts bots, client-side undercounts humans, and they observe different things — how to split events between them by consequence.
DA Orbit
The choice between server-side and client-side tracking is usually presented as a question about ad blockers, which undersells it. They measure genuinely different things, fail in different directions, and the interesting answer is not which one wins but which parts of your data belong on each side.
Short version: client-side sees the visitor, server-side sees the truth. Anything tied to revenue belongs on the server. Anything tied to behaviour has to come from the browser, because the server cannot see it.
What each one can actually observe
A server knows that a request arrived, what it asked for, what it sent back and how long that took. That is the complete list. Once the response leaves, the server is blind — it does not know whether the page rendered, whether anyone scrolled, whether the visitor read for four minutes or closed the tab in two seconds.
server.ts
// Server-side: the request handler reports the visit.
// Unblockable, but blind to everything after the response is sent.
app.get("/pricing", async (req, res) => {
await analytics.track({
path: req.path,
// The client hints the browser volunteered on the request.
userAgent: req.get("user-agent"),
referrer: req.get("referer"),
// Never the raw address — hash it with a daily-rotating salt.
visitor: hashVisitor(req.ip, req.get("user-agent")),
});
res.render("pricing");
});
A browser knows all the things the server cannot: viewport size, whether the tab was ever visible, how far down the page someone got, which button they clicked, how long they stayed, and the real Core Web Vitals the visit produced. It also knows the referrer with more nuance, and it can tell a prefetch from a human.
index.html
<!-- Client-side: the browser reports on itself.
Blockable, but it knows things the server never sees. -->
<script defer src="https://analytics.example.com/tracker.js"
data-site="abc123"></script>
This is the part that matters and gets skipped: server-side tracking is not a more reliable version of client-side tracking. It is a narrower one. Moving your analytics to the server does not recover the pageviews an ad blocker cost you — it changes what a "pageview" means.
They fail in opposite directions
Client-side undercounts
Blockers, strict privacy modes, JavaScript errors before the tracker loads, and visitors who leave before the script executes all remove real humans from your numbers. Depending on your audience this ranges from negligible to about a third of traffic — a developer-tools audience is the worst case, and a consumer audience the mildest.
Server-side overcounts
The opposite problem, and the one people are unprepared for. Your server logs every request, including the ones no human made: bots, uptime monitors, prefetchers, security scanners, link previews from every chat app someone pasted your URL into, and your own health checks. Raw server-side numbers are reliably inflated, sometimes dramatically, and the filtering that fixes it is work you now own.
Neither number is correct. One is missing humans, the other is counting machines. What you want is to know which is which — see why your analytics undercounts traffic for the client-side half of that.
The privacy trade is not what it looks like
Server-side tracking is often sold as the privacy-friendly option because no third-party script runs in the browser. That is true and it is not the whole picture: your server receives the visitor's IP address on every single request, whether you wanted it or not.
An IP address is personal data under GDPR. Storing it, or storing anything derived from it that could be reversed, puts you squarely in scope. The fix is to never persist it — derive an identifier at the edge and discard the input.
hash.ts
import { createHash } from "node:crypto";
// The salt rotates daily, so the same visitor produces a different id
// tomorrow. That is what makes the hash non-reversible in practice and
// keeps the identifier outside the definition of personal data.
function hashVisitor(ip: string, userAgent: string): string {
return createHash("sha256")
.update(`${DAILY_SALT}:${ip}:${userAgent}`)
.digest("hex")
.slice(0, 32);
}
The daily rotation is the load-bearing part. Without it the hash is a stable pseudonymous identifier that follows someone indefinitely, which is the thing regulators actually object to. With it, cross-day correlation is impossible even for you.
Done this way, neither approach needs a consent banner — the analysis in do you need a cookie banner for analytics applies to both, because it turns on storage and identifiers rather than on where the code runs.
The arrangement that actually works
Split by consequence rather than by preference. Ask what happens if a given event is silently lost.
- If losing it costs money, put it on the server. Purchases, signups, subscription changes, refunds. These must be exact, and the client is both blockable and forgeable.
- If it describes what a person did, it has to be client-side. Scroll depth, time on page, clicks, video engagement, form abandonment — the server has no visibility into any of it.
- Pageviews can be either, and should be one. Counting both without deduplicating produces double counts that look like growth. Pick the client for engagement accuracy or the server for completeness, and be consistent.
- Never trust a client-reported amount. Anything a browser sends can be edited by whoever is holding the browser.
checkout.ts
// The pattern worth copying: the browser reports engagement,
// the server reports the events that must not be lost.
// Browser — cheap, rich, and allowed to fail.
track("video_played", { id: "intro", at: 12 });
// Server — money. Never trust the client for this one.
await analytics.track({
event: "subscription_created",
plan: sub.plan,
amountCents: sub.amountCents,
});
If you only want one
Most sites should start client-side. The undercount is a known, roughly constant bias — and a constant bias still shows you trends correctly, which is what the majority of analytics decisions actually need. It is also a script tag rather than a change to your request handlers.
Add server-side when a specific number needs to be exact rather than directional, which in practice means when it appears in a financial report or an invoice. That is a narrower moment than it sounds, and recognising it is most of the skill.
Quantalog
HTTP endpoint
Try Quantalog on your own site
One script tag, no cookies, live numbers in about three seconds. Free forever on the Hobby plan.
Start freeKeep reading
Why your analytics undercounts traffic
Consent declines, content blockers, sampling, thresholding and beacons that never fire — the five mechanisms that remove visitors from your reports, and how to measure your own gap against server logs.
GA4 data thresholding: why your numbers are missing
Two GA4 reports, same date range, different totals. What triggers thresholding, how it differs from sampling, how to detect it in the API, and the four ways around it.