Skip to content

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.

PostHog5 min readships at docs/solutions/posthog/server-vs-client-events.md

Tags: posthog · analytics · nextjs · serverless · after · webhooks

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.

EventSideWhy
pricing_viewedbrowserThe server never learns a section scrolled into view
checkout_startedbrowserIntent: it is fine for it to undercount
subscription_startedserverMoney. Fired from the verified webhook
signup_completedserverThe account row is the fact
onboarding_step_completedserverIt persists, so the server knows
error_shownbrowserOnly 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:

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

// 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

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

const anonymousId = distinctId(); // from posthog-client.ts
await startTrial({ email, anonymousId });
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.