# Server events versus client events, and when each one lies

> A browser event is a claim, a server event is a record. Which side to fire from, why serverless drops events without after(), and how to keep the two from double-counting.

Finance says you had 214 new subscriptions last month. PostHog says 197. Both
numbers came out of systems that work. Someone is now going to spend a day
reconciling them, and the answer will be that `subscription_started` is fired in
the browser, on the page Stripe redirects to after checkout: the page that 17
people never reached because they closed the tab, lost signal in a lift, or
were behind an ad blocker.

The fix is not a better browser event. It is understanding that the two sides of
the network boundary measure different things.

## What each side can honestly tell you

**A browser event is a claim about the interface.** It is the only place that
can see a click, a scroll, an empty state, a form abandoned halfway. It is also
lossy in ways you cannot correct after the fact:

- ad blockers stop 10-30% of consumer traffic (a first-party proxy recovers most
  of it, but not all);
- a closed tab kills in-flight requests;
- offline and flaky connections drop them;
- anyone can open DevTools and fire whatever they like.

**A server event is a record of something your system did.** It happens after
the database write, in code the user cannot reach, and it is complete. It cannot
tell you anything about what happened between two page loads.

The decision rule that survives contact with production: **if the number being
wrong is a bug you would investigate, fire it from the server. If it being 90%
right is fine, the browser is cheaper and more informative.**

| Event | Side | Why |
|---|---|---|
| `pricing_viewed` | browser | The server never learns a section scrolled into view |
| `checkout_started` | browser | Intent: it is fine for it to undercount |
| `subscription_started` | server | Money. Fired from the verified webhook |
| `signup_completed` | server | The account row is the fact |
| `onboarding_step_completed` | server | It persists, so the server knows |
| `error_shown` | browser | Only the browser knows a user saw it |

## The trap: serverless drops server events silently

Moving an event to the server is not enough on its own. `posthog-node` batches
events and flushes them on a timer. On a long-lived Node server that is exactly
right. On a serverless function it is exactly wrong: the instant you return a
response, the function is frozen. The timer never fires. The batch is lost, and
nothing throws, so your logs are clean and your dashboard is missing a third of
last Tuesday.

The wrong shapes, both common:

```ts
// DON'T: the flush timer never runs
export async function POST(req: Request) {
  posthog.capture({ distinctId, event: "subscription_started", properties });
  return Response.json({ ok: true });
}

// ALSO DON'T: correct, but you just added 200ms to a webhook Stripe retries
export async function POST(req: Request) {
  posthog.capture({ distinctId, event: "subscription_started", properties });
  await posthog.shutdown();
  return Response.json({ ok: true });
}
```

The second one delivers the event and makes the response slower than the thing
it is reporting on. Under retry pressure it is how an analytics outage becomes a
billing outage.

## The right way: `after()` from `next/server`

`after()` schedules work to run once the response has been sent. On Vercel it is
backed by `waitUntil`, which keeps the invocation alive until the promise
settles. Capture during the request, flush after it:

```ts
// src/lib/analytics/posthog-server.ts
import { after } from "next/server";
import { PostHog } from "posthog-node";

let instance: PostHog | null = null;

function client(): PostHog | null {
  if (instance) return instance;
  const key = process.env.NEXT_PUBLIC_POSTHOG_KEY;
  if (!key) return null;

  instance = new PostHog(key, {
    host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
    flushAt: 1,      // queue nothing we might lose
    flushInterval: 0, // the timer would never fire anyway
  });
  return instance;
}

export function captureServer(args: {
  distinctId: string;
  event: string;
  properties?: Record<string, unknown>;
  groups?: Record<string, string>;
}): void {
  const posthog = client();
  if (!posthog) return;

  posthog.capture(args);

  after(async () => {
    await posthog.flush();
  });
}
```

Note what the signature does *not* do: it returns `void`, so nobody can `await`
it in a request path. Analytics must never add latency to a response and must
never be the reason a webhook returns 500.

Two details that bite people:

- **`after()` only works inside a request lifecycle.** In a cron script or a
  seed it throws, so wrap it and fall back to awaiting `flush()` directly, then
  call `shutdown()` before the process exits.
- **`after()` runs even when the response failed**, after a thrown error, a
  `notFound()` or a `redirect()`. That is usually what you want for logging, but
  it means "we returned 500" and "we captured the event" are not mutually
  exclusive. Capture after the write succeeds, not before it.

## Where the event goes in a payment flow

```ts
// src/app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
  const event = await verifyStripeSignature(request); // never skip this

  if (event.type === "customer.subscription.created") {
    const subscription = event.data.object;
    const user = await userForCustomer(subscription.customer);

    await recordSubscription(user.id, subscription); // the write is the fact

    captureServer({
      distinctId: user.id,          // the same id the browser identifies with
      event: "subscription_started",
      properties: {
        plan: subscription.items.data[0]?.price.lookup_key ?? "unknown",
        interval: subscription.items.data[0]?.price.recurring?.interval ?? "month",
        trial: subscription.trial_end !== null,
      },
      groups: { organisation: user.accountId },
    });
  }

  return Response.json({ received: true });
}
```

Every property comes from the verified webhook payload or from your database.
None comes from a request body a client composed: a route handler that captures
`{ plan: body.plan }` is instrumenting what the browser *claimed* was sold.

## Never fire the same event from both sides

The instinct after reading all this is "capture it in both places, one of them
will make it". Do not. Duplicate events double every count and there is no way to
tell the copies apart afterwards: same name, same person, milliseconds apart,
and PostHog has no reason to consider one canonical.

If you genuinely need both halves, they are two events with two names:
`checkout_started` (browser, intent) and `subscription_started` (server, fact).
The conversion between them is one of the most useful numbers you will have, and
it only exists because they are separate.

## Making the two sides join

The distinct id must match. The browser identifies with your database user id;
the server captures with the same id. For a signed-out flow (a marketing form,
a public trial) read the anonymous id in the browser and pass it to the server:

```tsx
const anonymousId = distinctId(); // from posthog-client.ts
await startTrial({ email, anonymousId });
```

```ts
captureServer({ distinctId: anonymousId ?? crypto.randomUUID(), event: "signup_completed", ... });
```

Without that, the server event starts a brand-new person and the funnel breaks
at exactly the step you care about most.

## Checking your work

- Trigger the flow, then look at **Activity** in PostHog. A server event appears
  with no corresponding network request in DevTools: that is normal.
- Deploy to a preview and repeat. Local `next dev` is a long-lived process, so a
  missing `after()` still delivers events on your laptop and loses them in
  production. This is the reason the bug reaches production so often.
- Compare one week of `subscription_started` against your own database. They
  should match exactly. If they do not, the event is still on the wrong side of
  the network.

---

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
