# 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.

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)

```tsx
// 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.

```tsx
// 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:

```tsx
// 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:

```tsx
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:

```ts
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

```ts
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
