Skip to content

The identify race that empties your signup funnel

Events fired before identify() stay on the anonymous profile forever. Here is why the merge is not retroactive, and the ordering that fixes it.

PostHog4 min readships at docs/solutions/posthog/identify-race-conditions.md

Tags: posthog · analytics · identify · funnels · nextjs · react

Your signup funnel says 3,000 people started and 40 finished. Support has not heard from 2,960 furious users, the database has 900 new accounts this week, and the "signup completed" number in PostHog matches roughly nobody's expectations.

Filter the funnel by any person property (plan, signup date, anything) and it gets worse. Remove the filter and the top of the funnel comes back.

This is almost always an identify race, and it is the single most common way an otherwise correct PostHog install produces unusable data.

What PostHog is actually doing

Every browser that loads the SDK gets an anonymous distinct id, a random uuid stored in a cookie. Events captured before anyone signs in belong to it.

When you call posthog.identify("user_123"), PostHog does two things: it starts attributing new events to user_123, and it merges the current anonymous id into that person, so the anonymous events from this browsing session become part of the person's history.

The important word is current. The merge attaches the anonymous id the SDK is holding right now. Two situations therefore lose data permanently:

  1. Events captured before the SDK loaded at all. There is nothing to merge.
  2. Events captured after a full page load and before identify runs, when the anonymous id has already been replaced or the person was already identified as someone else.

And crucially: merging is not retroactive across sessions. If someone browsed your marketing site on Monday, closed the tab, came back on Wednesday and signed up, Monday's anonymous id is not merged. This is a deliberate limit, not a bug: the alternative is a system that rewrites history.

The wrong way (and it looks completely reasonable)

// app/(app)/dashboard/page.tsx: DON'T
"use client";

export default function Dashboard() {
  const { user } = useSession();

  useEffect(() => {
    if (user) posthog.identify(user.id, { email: user.email });
  }, [user]);

  useEffect(() => {
    posthog.capture("dashboard_viewed");
  }, []);

  return <DashboardShell />;
}

Three separate faults, all invisible in review:

  • dashboard_viewed fires on mount; identify fires when the session promise resolves, typically a tick or two later. The event lands on the anonymous profile. Filter the funnel by a person property and it disappears.
  • Identify lives in a leaf component, so it only runs on pages that happen to include it. Sign in and land on /settings and you are never identified at all.
  • The email goes onto the profile, which means a deletion request now involves a third-party system as well as your database.

The really nasty version is a signup flow that captures signup_completed from a server action and identifies in the browser afterwards. The server event uses the database id, the browser events use the anonymous id, and nothing joins them until the next session, so the funnel shows a wall between "started" and "completed" that no product change will ever move.

The right way: identify where the session is, before the events

Identify once, high in the tree, in the same place that already knows who the user is. Everything below it can then capture freely.

// src/components/analytics-identity.tsx
"use client";

import { useEffect } from "react";
import { identifyViewer, resetViewer } from "@/lib/analytics/identify";

interface Props {
  viewer: { id: string; plan: string; role: string; signedUpAt: string } | null;
}

export function AnalyticsIdentity({ viewer }: Props) {
  useEffect(() => {
    if (!viewer) {
      resetViewer();
      return;
    }

    identifyViewer({
      id: viewer.id,
      traits: {
        plan: viewer.plan,
        role: viewer.role,
        signed_up_at: viewer.signedUpAt,
      },
    });
  }, [viewer]);

  return null;
}

Render it from the layout that already loads the session, so the values arrive with the first paint rather than after a client-side fetch:

// src/app/(app)/layout.tsx
import { AnalyticsIdentity } from "@/components/analytics-identity";
import { currentUser } from "@/lib/auth/session";

export default async function AppLayout({ children }: LayoutProps<"/">) {
  const user = await currentUser();

  return (
    <>
      <AnalyticsIdentity
        viewer={
          user
            ? { id: user.id, plan: user.plan, role: user.role, signedUpAt: user.createdAt }
            : null
        }
      />
      {children}
    </>
  );
}

identifyViewer() is idempotent: it remembers the last id it identified, so a re-render costs nothing, and identifying a different id resets first instead of stitching two humans into one profile.

Signup: identify at the moment the account exists

The signup flow is where the race actually costs money, because it is the funnel everyone reports on. The fix is to identify in the same handler that created the account, before capturing anything about it:

async function onSubmit(values: SignupValues) {
  const result = await createAccount(values); // server action

  // Identify FIRST: this merges the anonymous session that browsed pricing,
  // read the docs and started the form into the new person.
  identifyViewer({
    id: result.userId,
    traits: { plan: "free", signed_up_at: result.createdAt },
  });

  capture("signup_started", { source: "pricing" });
  router.push("/onboarding");
}

And on the server, capture the fact with the same id:

captureServer({
  distinctId: user.id,
  event: "signup_completed",
  properties: { method: "email", invited: false },
});

Both halves now agree on the identity, so the funnel joins.

Sign-out is the other half of the bug

export async function signOut() {
  await auth.signOut();
  resetViewer(); // before the redirect, always
  router.push("/");
}

Without the reset, the next person to use that browser inherits the previous user's distinct id. On a shared laptop or a demo machine, one profile ends up containing two people's behaviour, and nothing after the fact can separate them.

Do not reset on a route change or a token refresh. Reset means "a different human is here now".

Verifying you actually fixed it

  1. In an incognito window, open the marketing page, then sign up.
  2. In PostHog open the new person. Their event list should include the pre-signup page views, not start at signup_completed.
  3. Filter your signup funnel by a person property such as plan. If the top of the funnel collapses, events are still landing on anonymous profiles.
  4. Sign out, sign in as someone else, and confirm two distinct persons exist.

The rule that prevents it coming back

Identify belongs in exactly one place per app: the provider or layout that loads the session. Any posthog.identify() call in a feature component is a bug waiting for a slow session fetch, and the fetch is always slower in production than on your laptop.