For two years, my portfolio’s analytics thought my laptop and my phone were strangers. Same person, same couch, same website. Two visitors. And when a friend joined my wifi with the same browser, the analytics quietly merged us into one.
The culprit was the visitor identity itself: sha256(ip + userAgent). Half the indie web runs on this scheme, and it breaks in both directions. A network change forks one person into many. A shared network merges many people into one. I even had a cleanup script whose whole job was apologising for it after the fact.
So I rebuilt visitor identity properly, with browser fingerprinting as one layer. Not the foundation. That distinction turned out to be the whole lesson.
Fingerprinting can’t be your primary key
Before writing any code I looked at what browsers actually do to fingerprinting in 2026:
Safari ships Advanced Fingerprinting Protection by default. Canvas noise, “Apple GPU” instead of your real GPU, fixed values everywhere.
Firefox standardises audio and canvas output in strict mode and buckets hardware into groups like “Apple M1, or similar”.
Brave randomises canvas and WebGL per session, per site. They call it farbling.
Chrome, which is most of the traffic, still produces stable device-specific output.
So a fingerprint is stable for most visitors and churns every session for the privacy-conscious ones. Build on it alone and your “unique visitors” number is fiction for exactly the users who care most.
Three layers, one resolver
Every tracking ping now carries up to three identifiers, and the server resolves them into one canonical visitor:
Priority 1 vid : first-party UUID (localStorage + cookie mirror, 400 days)
Priority 2 fp : FingerprintJS v5 hash + raw components
Priority 3 ip_hash : the old sha256(ip + UA), demoted to last resortThe resolution ladder walks down until something matches:
exact_vid : the stored UUID matched. Strongest signal.
exact_fp : storage was cleared, but the fingerprint hash matched.
The server re-issues the old vid and the client re-adopts it.
fuzzy_fp : the hash drifted (browser update), but enough components
still match. More on this below.
ip_fallback : nothing else matched. The old world.
new : genuinely never seen before.Every visitor gets tagged with the layer that resolved them, and the admin dashboard shows that identity_confidence honestly. I would rather see “this visitor is an ip-fallback guess” than pretend everything is exact.
FingerprintJS, by the way, went back to MIT licensing in v5 (v4 was BSL), which is what made it viable here. It loads as a lazy chunk with a 1.5 second budget, and if it times out or throws, the ping just goes out with the other two layers. Identity must degrade, never veto.
Fuzzy matching absorbs the drift
A fingerprint hash is all-or-nothing: one browser update changes one component and the whole hash flips. But the raw components barely moved. So I store the components too, and when the hash misses, a fuzzy pass scores the incoming set against recent stored ones:
Weighted equality over shared components. Canvas, audio, fonts, WebGL and friends count double; volatile ones like screen frame and timezone offsets are excluded entirely.
A match requires a score of at least 0.85 and at least 8 shared stable components, because a threshold alone means nothing when only three components overlap.
Every fuzzy match is logged to an audit table with its score, so a bad merge is traceable instead of silent.
In verification, a simulated browser update (same components, one changed, different hash) resolved back to the same visitor at a score of 0.947. That is the case the old system lost every single time.
Merges leave a paper trail
Sometimes layer 1 and layer 2 disagree: the cookie says visitor A, the fingerprint says visitor B. That means two identities were created for one human at some point. The resolver prefers the first-party id, repoints B’s fingerprint to A, and writes a merge event into an identity_events audit log. Who absorbed whom, which fingerprint, when.
My old setup did this with an offline script that grouped rows by IP and hoped. Now it happens at resolution time, reversibly, with receipts.
If you track, disclose
Fingerprinting for analytics sits under GDPR and ePrivacy rules in the EU, and Malaysia’s PDPA is trending the same direction. Even for a personal portfolio, the honest move costs almost nothing:
Raw IP addresses are never stored. Identity uses hashes, geo stays at city level.
A plain-language privacy page names every identifier and what it is for. No legalese.
Every visitor can be erased with one action: a cascade across all eight tables that reference them, verified down to zero orphan rows.
What actually changed
Cross-network returns, the exact case sha256(ip + UA) structurally cannot see, now show up in the dashboard with their own metric. My Telegram ping says “returning visitor, 3rd visit, last seen 2d ago, resolved via exact_vid” instead of greeting me as a stranger every time I leave the house.
And the fingerprint? It earns its keep in exactly one scenario: storage got cleared, the fingerprint bridged the gap, and the visitor got their old identity back. That is it. That is the job.
Fingerprinting is a fallback, not a foundation. A first-party UUID in localStorage beats every canvas trick ever written. It is deterministic, it is honest, and no browser is at war with it. Build the boring layer first, and let the clever layer catch what falls through.
