Server-side events from a backend
When to track from your backend, how to thread anonymous_id, and patterns for batching.
Critical events should originate on the server. The browser is hostile territory: ad blockers, refreshes, navigations, network drops. If you only fire purchase from the success page, your revenue numbers are wrong.
What to fire server-side
$pageview
✅ — auto by SDK
—
clicked_*, viewed_*, hovered_*
✅
—
signup
optional
✅ — fire on user creation
subscription_*, purchase, refunded
—
✅ — fire on transaction
feature_used (gated by auth)
optional
✅ — fire from the API handler
Webhook events from Stripe / Slack / Resend
—
✅
Threading anonymous_id from the browser
The whole game is making sure server-side events still carry the visitor's anonymous_id — otherwise you can't stitch them back to the browser session. Three options, pick one.
Option A — pass it in the request body
The simplest. The browser includes the SDK's anonymous id when calling your API.
import { getAnonymousId } from "@millimetric/track";
await fetch("/api/signup", {
method: "POST",
body: JSON.stringify({
email,
plan,
anonymous_id: getAnonymousId()
})
});Server reads it and passes through:
Option B — first-party cookie
If you'd rather not touch every fetch call. Mirror the SDK's localStorage value into a cookie:
(This is the only "cookie" in the system, and it's your cookie, not Millimetric's. We never set one.)
Option C — derive it server-side and tell the SDK
For SSR-heavy apps (Next.js with cookies, Rails with sessions), you might prefer the server to own the id and the browser to consume it.
Long-running server vs serverless
Long-running (Node server, Express, Fastify, Bun, Express on a VPS)
Batch in the background. The internal timer is unref'd, so it won't keep your process alive on its own.
Serverless / edge (Vercel, Cloudflare Workers, Lambda, Deno Deploy)
The instance freezes after the response. Always await flush() before returning, or queued events disappear.
Webhooks (Stripe, Slack, Resend, etc.)
Treat webhook events as server-side track() calls with idempotency. Use the provider's event id as event_id:
The server doesn't dedupe on event_id yet, so if Stripe retries the webhook you might get two rows. Either:
De-dupe in queries (
SELECT DISTINCT … ORDER BY event_id).Track receipts in your own DB and skip if already processed before calling
track().
Batching backfills
Loading historical data — old orders from your DB into Millimetric — is exactly what /v1/batch is for.
/v1/batch is rate-limited to 5 requests/sec — at 1000 events/batch that's 5,000 events/sec sustained, fine for any reasonable backfill.
Common pitfalls
Not awaiting
flush()in serverless. Events queued but never sent. Alwaysawait flush()before returning.Putting
sk_*inNEXT_PUBLIC_*/VITE_PUBLIC_*env vars. Bundlers will inline the secret in the browser bundle. Use a non-public env var name.Server-side events without
anonymous_id. Stitching breaks. Pass the id from the request.Tracking the same event from both client and server. Pick one source of truth. For
signup, the server is canonical.
Last updated
Was this helpful?