8/29/20268 min read

How To Solve Self-Referral Issues In PostHog For Web3 Analytics

How To Solve Self-Referral Issues In PostHog For Web3 Analytics

We were pulling a routine referrer breakdown when something odd showed up in the top ten: our own subdomains, all ranking as referring domains, some of them ahead of Google and X.

That's the kind of finding that looks like an attribution problem. It isn't, quite. It's a session problem — and it's worth being precise about that, because it's easy to conflate with a nearby but different problem: identity. This post walks through how to tell the two apart, with the console commands and PostHog queries to prove which one you're actually looking at.

Why this bites web3 products especially hard

Most products get a stable user ID the moment someone signs up. Web3 products don't work that way. A visitor can browse and trade for days before ever connecting a wallet — and the moment they do connect is often the first time PostHog has anything durable to anchor an identity to. Everything before that depends entirely on the session layer holding together on its own, across reloads and across subdomains.

That's why this class of bug is more expensive here than in a typical SaaS funnel. Keep that in mind as we go — but don't let it stand in for the actual mechanism, which is more specific than "wallet connect breaks things."

Ruling out the obvious suspects first

Suspect 1: cookie domain misconfiguration. Open the dev console on the subdomain in question and run:


document.cookie
  .split('; ')
  .filter(c => c.startsWith('ph_'))

If you see a ph_<project-key>_posthog cookie on both subdomains with matching distinct_id and $device_id, cookie sharing looks fine on the surface. Don't stop there — parse the cookie value and check $initial_person_info.u, which records which URL the cookie was originally created on. If that points to a different subdomain than the one you're standing on, the cookie is inherited via the shared parent-domain cookie, not written locally — and any visitor who never touches the originating subdomain first gets nothing.

Suspect 2: the session idle timeout. In PostHog, build a trend on $pageview, breakdown by session duration bucket, over the last 30 days, on the affected subdomain. If sessions were resetting because of the idle timeout, you'd expect a spike in the bucket right below the threshold. If that bucket is flat, the timeout isn't your cause.

The diagnostic toolkit: console commands to run on each subdomain

Run these in an incognito window, on each subdomain.

1. Check current identity and session, then reload and check again:


posthog.get_distinct_id()
posthog.get_session_id()

A healthy subdomain returns identical values before and after a reload. If both change on every reload, nothing is surviving on that subdomain.

2. Check what's actually in storage:


Object.keys(localStorage).filter(k => k.startsWith('ph_'))

An empty array means PostHog isn't writing to localStorage on this subdomain at all — regardless of what the cookie check showed.

3. Check the SDK's own persistence config:


posthog.config.persistence

Compare this across every subdomain you're auditing. A value of "memory" on some and "localStorage+cookie" on others is the inconsistency that explains everything above.

4. Confirm it's not tied to auth state:


posthog.get_distinct_id()   // before connecting a wallet
posthog.get_distinct_id()   // after connecting
posthog.get_distinct_id()   // after a refresh, still connected

If the third call reverts to a brand-new anonymous ID despite the wallet still showing connected in the UI, the setting is a blanket one, not conditional on auth.

These four checks confirm that something resets. They don't yet tell you whether what's resetting is the session (which drives $referring_domain) or the identity (which drives distinct_id merging). That distinction is where the rest of this post lives, and it's the part worth getting right before you touch any config.

Session and identity are two different layers — and only one of them causes self-referrals

It's tempting to treat distinct_id resetting and $referring_domain misreporting as the same bug. They're not, and conflating them leads to fixing the wrong thing.

  • Identity (distinct_id) is about knowing that two events belong to the same person. PostHog's posthog.alias() merges an anonymous ID into an identified one. This is what a cross-domain "bridge" parameter is typically for: pass the outgoing session's ID via a URL parameter, read it on arrival, call alias().

  • Session ($session_id / $referring_domain) is about whether a browsing session continues or restarts. This is governed entirely by what's persisted in the browser — cookie or localStorage — and it is completely independent of whether identity gets merged afterward.

The part that's easy to miss: alias() does not touch $referring_domain, and it does not stop a new session from starting. You can have a perfectly working identity bridge — every anonymous visitor correctly merged into their eventual wallet-identified person — and your self-referral rows will look exactly the same, because the browser still reports the previous internal page as the referrer the moment a new session starts on the destination subdomain.

If your team already has a bridge parameter that's calling alias() on arrival and merging identities correctly, that's good news for your person data — and it's very likely not going to move the self-referral numbers at all. Worth checking directly:

Phase A — confirm the bridge is merging identity (it might already be working)

Tag a cross-subdomain link, click it, and pull the resulting event from PostHog's live event stream.


event: $pageview
distinct_id: 0x-a1b2c3d4e5f6-wallet-address
$referring_domain: marketing.yourdomain.com

If distinct_id correctly reflects the merged/identified user, your bridge is doing its job on the identity side. Notice that $referring_domain still shows the internal subdomain regardless — that's the tell that identity and session are decoupled.

Phase B — confirm session isn't continuing


posthog.get_session_id()   // on the originating subdomain, just before the click
posthog.get_session_id()   // on the destination subdomain, immediately after

If these differ, a new session started on arrival — and that's what produces the self-referral row, independent of whatever distinct_id says.

Root cause: no shared session across subdomains

Once you've separated the two, the actual mechanism is straightforward: subdomains with persistence: 'memory' write nothing to the browser, so nothing survives a subdomain hop for the session layer to continue. A new session starts, and the browser correctly attributes it to wherever the click came from — your own previous page. This has nothing to do with whether identity merging is wired up correctly; it will misreport referrers even for a visitor whose identity is perfectly tracked end to end.

A cookie-free alternative: fix the report, not the storage

Forward the original external referrer alongside your existing bridge parameter when linking across subdomains:


const url = `https://app.yourdomain.com/page?internal_referral_id=${sessionId}&original_ref=${encodeURIComponent(document.referrer)}`;

Then, on arrival, rewrite $referring_domain using that forwarded value instead of leaving it to default to the internal subdomain. This doesn't stop a new session from starting — it just makes sure the report reflects where the person actually came from (X, Google, direct) rather than the internal hop that happens to be the browser's technical referrer.

It's a smaller change, it needs no cookie and no consent review, and for most teams it's the more honest fix anyway: what you actually want to know is acquisition channel, not session mechanics.

One coverage gap to know about either way: if your bridge parameter is only attached by rewriting <a href> links on click, direct navigation and anything that isn't a standard link won't carry it. Check what share of your actual cross-subdomain traffic arrives via tagged links versus other paths before you assume either fix is catching everything.

A related but separate issue: junk person records

Worth a brief mention, because it's easy to bundle in with self-referrals but is genuinely a different problem: if a subdomain calls posthog.group() on every page load — even before a person is identified — that alone can re-enable full person processing, silently undoing a person_profiles: 'identified_only' setting you thought was in effect. The result is a person record created for every anonymous page load, which inflates your person table and ingestion cost, independent of anything covered above. If you're seeing an unexpectedly large number of never-identified profiles, check what's calling group() and when — but don't expect fixing it to change your referrer report or your session counts. It won't.

Verifying whichever fix you ship

  • If you added a shared cookie: repeat the session-ID check across a subdomain hop and confirm it now matches instead of resetting; confirm $referring_domain on the destination event no longer shows the internal subdomain.

  • If you rewrote $referring_domain instead: confirm the rewritten value reflects the original external source, and confirm this is happening consistently for traffic that arrives via the tagged path (and flag, rather than assume away, whatever share arrives outside it).

  • Either way: re-pull your top referrers report and confirm internal subdomains have dropped out of it.

Why teams end up here anyway

Usually not negligence — a genuine privacy-conscious instinct not to persist anything before a visitor has done something consent-adjacent, implemented as a blanket setting. The mistake isn't the instinct, it's assuming that identity-layer fixes (aliasing, bridging distinct IDs) would also resolve a session-layer symptom (self-referrals). They're adjacent problems that happen to show up in the same referrer report, solved by different mechanisms, with different costs.

Takeaway

Before you touch a persistence setting, work out which layer is actually broken. alias() and identity bridges fix who a session belongs to. Only a continued session — via a shared cookie, or a rewritten referrer as a workaround — fixes where the report says it came from. Conflating the two gets you a shipped fix and an unchanged referrer report.

ShareXLinkedIn
A
Adil KhanGrowth Lab Insights