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:
// 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 awaitingflush()directly, then callshutdown()before the process exits.after()runs even when the response failed, after a thrown error, anotFound()or aredirect(). 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 devis a long-lived process, so a missingafter()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_startedagainst your own database. They should match exactly. If they do not, the event is still on the wrong side of the network.