Every way analytics tools recognise a returning browser, from cookies and localStorage to fingerprints and salted hashes, exactly what Margin computes, and the trade-offs vendors don't usually print.
"Unique visitors" is an identity problem wearing a metrics costume. To count people instead of pageviews you have to recognise that two requests came from the same person. For twenty-five years the answer was a cookie: set a random ID once, read it back forever. It works brilliantly, which is exactly why it now comes with a consent banner, an ePrivacy paper trail, and a growing population of browsers and extensions that eat it.
Margin sets no cookies, writes nothing to localStorage, and loads no fingerprinting library. It still shows you unique visitors. This post walks through the full menu of identity techniques, from cookies to salted hashes, explains which one Margin picked, and is honest about what it costs, because the trade-offs are real and mostly under-advertised.
The menu of identity techniques
Every analytics tool answers the same question: is this request from a browser I've seen before? There are only a handful of places an answer can come from, and each sits somewhere on a line between accuracy and privacy.
Strategy 1: a cookie
The classic. On first visit, mint a random ID and hand it to the browser; the browser dutifully returns it with every request until it expires.
// First-party analytics cookie, GA-style
let id = readCookie("_uid");
if (!id) {
id = crypto.randomUUID();
document.cookie = `_uid=${id}; Max-Age=63072000; SameSite=Lax; Path=/`;
}
// Two years of stable identity: cross-day uniques,
// retention curves, attribution windows. It all just works.
Third-party cookies (an ID shared across every site that loads the vendor's script) are effectively dead: blocked by Safari and Firefox for years and heavily restricted in Chrome. First-party cookies like the one above still work technically, but legally they're stored state on the visitor's device, which is exactly what the ePrivacy rules gate behind consent. Hence the banner. And even with consent, Safari's ITP caps script-set cookies at seven days, so your "two years of identity" quietly becomes one week for a large slice of traffic.
Strategy 2: localStorage
The tempting dodge: "the rules say cookies, so we'll keep the ID somewhere else."
// Same idea, different drawer
let id = localStorage.getItem("uid");
if (!id) {
id = crypto.randomUUID();
localStorage.setItem("uid", id);
}
This buys you nothing. Article 5(3) of the ePrivacy Directive covers "the storing of information, or the gaining of access to information already stored" on the user's device. It never says the word cookie. localStorage, sessionStorage, IndexedDB, cache tricks like ETag respawning: all the same rule, same consent requirement. Swapping the storage API and deleting the banner is a compliance bug, not a strategy.
Strategy 3: fingerprinting
If you can't store an ID, make the browser itself the ID. Enough quirks, combined, are unique.
// Pseudocode. Nothing is stored; the browser's shape is the identifier.
const fingerprint = await sha256(
[
navigator.userAgent,
navigator.language,
screen.width,
screen.height,
Intl.DateTimeFormat().resolvedOptions().timeZone,
await canvasQuirks(), // GPU + font rendering differences
await audioQuirks(), // DSP floating-point differences
].join("|"),
);
This is the darkest corner of the menu. It's durable (you can't clear what was never stored), invisible to the user, and research shows sites reach for it precisely when consent for cookies is refused. Regulators treat gaining access to device information this way as in scope of the same ePrivacy rules, and browsers actively sabotage it: Safari and Firefox deliberately blur the very APIs it reads. Any analytics vendor "solving" cookieless uniques with a fingerprinting library has just rebuilt the problem with worse optics.
Strategy 4: derive an ID on the server
Everything above stores something on, or reads something from, the visitor's device. The remaining option is to use only what the server was already handed: every HTTP request arrives carrying an IP address and a user agent. Hash them together and the same person produces the same value, at least until their IP or browser changes.
Raw, that's just fingerprinting moved server-side: a durable pseudonymous ID. The insight the privacy-first tools converged on is to make the hash expire. That's the family Margin belongs to, and it deserves its own section.
Strategy 5: give up on identity
Worth naming: some tools simply don't count uniques. Pageviews, referrers, and heuristic "sessions" (a gap of thirty minutes starts a new one) need no identity at all. It's the most private option and a perfectly honest one, but "how many people" is usually the first question analytics is asked, so most tools don't stop here.
The industry's trick: a hash that expires
Every serious cookieless analytics tool converged on the same idea: derive the visitor ID from things the server already sees, and scope it to a single day so it can't become a tracking ID.
Plausible computes hash(daily_salt + domain + ip + user_agent). The salt is a random value rotated every 24 hours and old salts are deleted, so yesterday's IDs can't be linked to today's, not by an attacker and not by Plausible themselves. Fathom hashes the IP, user agent, hostname and a site-scoped salt with SHA-256, discarding the IP immediately after. GoatCounter keeps a site + IP + User-Agent → random ID mapping in memory for at most eight hours and only ever persists the random ID.
The salted version looks like this:
// Pseudocode: the Plausible-style scheme
const salt = await saltStore.today();
// random 128+ bits, rotated every 24h, old value deleted forever
const visitorId = sha256(`${salt}${domain}${ip}${userAgent}`);
// store visitorId; never store ip or userAgent
The common properties:
- The same person produces the same ID within a day, so daily uniques are accurate.
- The inputs (IP, user agent) are never stored; only the derived ID is.
- The ID expires with the day, so there is nothing durable to build a profile on.
And note the one operational requirement hiding in that snippet: saltStore. Somewhere, a single source of truth has to generate the salt, share it with every server doing the hashing, and reliably destroy it a day later. Keep that in mind.
What Margin computes
Margin's version is small enough to quote in full. It runs in the ingest handler:
const day = new Date().toISOString().slice(0, 10);
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(`${day}:${site}:${ip}:${userAgent}`),
);
// first 16 bytes, hex-encoded
Same family as the tools above, with one architectural difference that changes the trust model: where it runs.
Margin has no third-party event endpoint. The SDK mounts the ingest route on your app, so the browser talks to your domain and the visitor hash is computed on your server, from a request only your infrastructure ever sees. What gets forwarded to Margin is the truncated digest. The raw IP and user agent never leave your stack.
browser ── POST /api/margin (your app)
│ derive visitor hash from ip + ua
└──> Margin API (digest only, org key)
With a hosted script-and-endpoint tool, "we hash and discard your visitors' IPs" is a promise you take on faith, because their servers necessarily see the raw request. In Margin's model there is nothing to take on faith at that hop: the vendor never receives the identifying inputs in the first place.
The part we should be honest about
You may have noticed Margin's hash input uses the date where Plausible uses a random daily salt. That is a deliberate trade-off, and it has a cost.
A random salt exists to make the hash non-recomputable. Without one, anyone who holds the digests and can enumerate plausible inputs can run a membership test:
// The question a secret salt makes unanswerable:
// "was IP X with browser Y on this site last Tuesday?"
const suspect = await visitorHash("2026-07-07", site, candidateIp, candidateUa);
const wasThere = storedDigests.has(suspect);
Salt-less, day-scoped hashes are recomputable by construction. So why accept that? Remember the saltStore from the Plausible snippet. Plausible can run one because all their hashing happens in one place they control. Margin's handler is stateless code running on your infrastructure, possibly across dozens of serverless instances, and a rotating secret shared across all of them, deleted on schedule, is exactly the kind of operational surface a mounted handler exists to avoid.
So the honest description of Margin's visitor ID is pseudonymous, not anonymous: it cannot be reversed, but for one day it can be recomputed by someone who already knows what they're looking for. Two things bound the damage. The ID is dead the next day; there is no cross-day linkage to accumulate. And the party best positioned to brute-force it already operates the server that saw the raw request anyway.
What day-scoping costs everyone
These apply to every tool in this family, ours included:
-
There are no cross-day uniques. In every day-scoped tool, "weekly visitors" is really:
const weekly = days.reduce((sum, day) => sum + day.uniques, 0); // a person who visits every day counts seven timesCookie-based tools genuinely measure something here that cookieless tools structurally cannot.
-
Shared IPs merge people. An office, a university network, or a mobile carrier behind CGNAT can put thousands of people on one IP; those with the same browser build collapse into one visitor. This undercounts, sometimes substantially, and it undercounts non-uniformly: mobile-heavy audiences are hit hardest.
-
Identifier churn splits people. A browser update changes the user agent mid-day, a phone hops from Wi-Fi to cellular. Same person, new hash, two "uniques".
-
No multi-touch attribution, no retention curves, no LTV. If your product decisions need "did the person from last Tuesday come back?", this model cannot answer it, by design.
We think that's the right trade for web analytics: the numbers stay directionally accurate, the failure modes are legible, and the privacy story stops depending on user-hostile machinery.
When you already know who they are
There is one big exception to those limits: signed-in traffic. If someone has an account, your database already holds a durable identifier for them, and no derivation trick is needed. A hosted analytics script can't see your session. Margin's handler runs inside your app, so it can:
// In your route, where your session is visible
export async function POST(request: Request) {
const session = await auth(request);
return createHandler(margin, {
$visitor: session ? session.user.id : undefined,
})(request);
}
// Anonymous requests fall back to the day-scoped hash automatically.
For logged-in users this restores everything day-scoping takes away: cross-day uniques, retention, "did the person from last Tuesday come back". And it does it without re-entering the consent-banner conversation, because nothing new is stored on the device; the only stored thing is your app's own session cookie, which is strictly necessary and already exempt.
Be clear-eyed about what it is, though. Unlike the anonymous path, this identifier does leave your stack: Margin now processes your account data, the same as any other tool you send a user ID to, and that belongs in your privacy policy alongside the rest of your subprocessors. Your unique counts also become a blend, stable IDs for members and daily hashes for everyone else. That's usually exactly what product analytics wants, but it's a choice you make deliberately, one route at a time, not a default.
"So I can delete my cookie banner?"
Mostly, but let's be precise, because this is where cookieless marketing tends to overreach.
The consent banner requirement comes from the ePrivacy rules about storing or reading information on the visitor's device. Strategies 1 through 3 all trip it, as we saw, and localStorage or fingerprinting swaps don't escape it. Strategy 4 stores nothing and reads nothing from the device, so there is nothing to consent to. That part is clean.
GDPR is a separate question. Regulators treat a salted-or-hashed IP as pseudonymised personal data, not anonymous data; hashing does not remove data from GDPR's scope. Processing it for aggregate statistics is the textbook case for the legitimate-interest basis rather than consent, and the fact that the raw identifiers never leave your own infrastructure and the derived ID expires daily makes that argument about as strong as it gets. But "no banner" is not "no obligations": it belongs in your privacy policy, and none of this paragraph is legal advice.
See it live
Our own dashboard is public. The demo at /p/inmargin.io is this exact pipeline counting visitors to this site, cookie-free. If this post sends you there, you'll be a unique visitor today and, pleasingly, an anonymous nobody tomorrow.