One user, three ghosts: fix your funnel with identify()
Your funnel says signup conversion is 4%. It is actually 11%. The missing users didn’t bounce - they came back on another tab and got counted as someone new. Every number downstream of that split is wrong.
The anonymous gap eats your numbers
Before signup, a visitor has no user id, so analytics assigns an anonymous one. After signup they have your real id. Unless those two identities are explicitly merged, every user who browsed before registering exists twice: an anonymous ghost who "viewed the paywall and bounced" and a fresh account that "converted instantly with no prior touchpoints". Both records are fiction. Your time-to-convert is understated, your paywall views are overcounted, attribution is severed mid-journey - and the worst part is the dashboard renders all of it confidently, because broken identity does not look broken. It looks like data.
identify() is the stitch
The Vevee SDK’s analytics.identify takes the real user id and, crucially, the anonymousId that was active before login. When both are present, the merge is total: every historical event row, every distinct-id mapping, and the person profile are rewritten onto the identified person. The ghost ceases to exist retroactively - the pre-signup pageviews and paywall impressions become part of the real user’s timeline, where they belong.
// Right after signup or login succeeds
await vevee.analytics.identify(
user.id, // your canonical id
{ plan: 'free', role: user.role }, // $set - update profile
{ signup_source: utmSource }, // $set_once - first-touch, never overwritten
anonymousId, // the pre-login identity to merge
);Idempotent means you can stop being careful
The classic identity bug is calling identify in exactly one place - the signup handler - and missing every other door into the app: OAuth callbacks, magic links, the session that was already logged in on a second device. Vevee’s identify is idempotent, which licenses the lazy-but-correct pattern: call it on every authenticated page load. Already merged? No-op. New anonymous session from a new device? Merged. The $set_once properties protect first-touch attribution from being overwritten on call number two hundred, while $set keeps mutable profile fields current.
alias() for the identities you create yourself
Sometimes the split is not anonymous-versus-known but two known ids for one human, for example after a migration from email-as-id to UUIDs. For analytics, alias(a, b) folds person a into person b. To move usage, limits, subscriptions and credits too, call customers.alias({ from: a, to: b }): every later call with either id behaves as b. For the analytics side, the only difference from identify is that alias writes no profile properties: it is pure identity surgery. Between the two calls, every "wait, are these the same person?" situation has a one-line answer.
What the fixed numbers change
After the merge discipline is in, the corrections are not cosmetic. Signup conversion rises because the denominator stops double-counting returners. Time-to-convert stretches to the truth, which reorders what you build - users who "converted in one session" actually deliberated for six days, so that nurture email you cancelled mattered after all. Pre-signup paywall views attach to people who eventually paid, revealing which anonymous touchpoints predict revenue. None of this required new tracking - the events were always there, attributed to ghosts. One call per page load put them back on the people who fired them.
Keep reading
Reading a split funnel
The funnel chart tells you how many got through. The split table under it tells you who. Here is a row by row reading of one, from a study app's mock month.
AnalyticsHybrid mode for privacy: put your own server in the middle
The browser never talks to Vevee. It talks to a forty-line route on the app's own server, which decides what leaves and what does not.