For the complete documentation index, see llms.txt. This page is also available as Markdown.

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

Event
Browser
Server

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

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. Always await flush() before returning.

  • Putting sk_* in NEXT_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?