Concepts
Client-side and server-side tracking
The two places events come from, what each one is good for, and the events only the server can send.
Events reach your analytics tool from two places. The user's browser sends some of them. Your own server sends the rest. Most production setups use both, and the decision of which event goes where matters more than teams expect. Send the wrong event from the wrong place and you lose either accuracy, context, or the event entirely.
The rest of this chapter is about the two sources, what each one is good for, where each one breaks, and the handful of events that only the server can send.
What a client-side event actually is
A client-side event fires from the user's browser. The analytics SDK runs in the page, listens for things you've instrumented, and sends a network request directly from the browser to the analytics provider.
// Browser, after the user clicks "Create project"
amplitude.track("Project created", {
template: "blank",
workspace_id: "ws_4421",
});The request leaves the user's machine. It carries the device_id and session_id the SDK already manages, whatever user_id you've set, and a stack of context the SDK assembled on its own. Page URL, page title, referrer, user agent, screen size, browser language, IP-derived location. You wrote three lines. Amplitude received about thirty fields.
What a server-side event actually is
A server-side event fires from your backend. Your code calls the analytics provider's HTTP API directly with whatever payload you assemble.
// Server, after Stripe confirms a subscription renewal
await amplitude.track({
event_type: "Subscription renewed",
user_id: "usr_8842",
device_id: "a1b2",
session_id: 1716822041,
event_properties: {
plan: "pro",
amount: 49,
currency: "USD",
},
});The user's browser is not involved. The event doesn't depend on a tab being open, on a script being allowed to run, or on a network the user controls. If your server has the data and decides to send it, the analytics tool gets it.
Notice what's missing compared to the client-side example. The page URL, the device, the referrer, the rough location. None of that exists on the server. Anything you want on a server-side event has to be passed in by hand.
How the two compare
| Property | Client-side | Server-side |
|---|---|---|
| Where it runs | The user's browser | Your backend |
| Auto-captured context | Page, referrer, device, browser, IP location, session | None. You pass what you want |
| Reliability | 80–95%. Loses to ad blockers, tab closes, network failures | Effectively 100% if your code runs |
| User present? | Required | Not required |
| Can be tampered with | Yes. Anyone can block, modify, or replay the request | No. The call comes from your server |
| Session stitching | Automatic | Manual. You pass device_id and session_id in |
| Where revenue events live | Almost never | Almost always |
The two sources cover different ground, and the decision of where to put each event follows from what the event needs to survive.
What goes wrong client-side
Three things eat client-side data, in roughly this order of impact.
Ad blockers and privacy tools block the request before it leaves the browser. Brave blocks analytics by default. Safari's privacy modes block some of it. uBlock Origin, Ghostery, and Privacy Badger block more. For a general consumer audience you lose 10–15% of events to this. For a developer-heavy SaaS audience the loss can hit 40%, because developers run the strict blockers.
Tabs close before the SDK gets a chance to send. An event queued in browser memory is not the same as an event delivered. SDKs use sendBeacon on unload to flush the queue, and it works most of the time. It does not work all of the time. A user who clicks a CTA and closes the tab a fraction of a second later may never have that click recorded.
The browser is a hostile environment more broadly. The user can install an extension that rewrites your payload. They can open the network tab and replay a request. They can sit behind a corporate proxy that drops outbound calls to specific domains. Most of your users are doing none of this, and the failure rate is still high enough to matter for any number you plan to report on.
Underneath all three is a simpler fact. A client-side event only fires while the user is in your app. The browser is not running when the subscription renews at 3 a.m. The browser is not running when your cron job converts a trial. The browser is not running when Stripe sends a webhook about a failed charge.
What client-side gives you
The SDK captures the page, the referrer, the device, the browser, the rough location, the active session, the campaign parameters in the URL, and a dozen other fields without you writing a line of code. Replacing that on the server means passing every one of those values through every layer of your stack until it reaches the tracking call, and most teams never get past the first two.
Client-side events also arrive already inside the user's session. They line up in the timeline without any work on your part. Server-side events have to be wired into that session manually, which the last section of this chapter covers.
The third thing is UI-level granularity. Server code knows the user updated their profile, but it usually does not know they clicked the avatar dropdown, hovered for two seconds, and then clicked Settings. If you want behavioral detail at the level of the interface, client-side is the only place it lives.
What goes wrong server-side
The hardest part is identity. A server-side event needs the right user_id, device_id, and session_id to land on the same Amplitude ID and the same browsing session. The user_id is usually easy because authentication has already happened. The device_id and session_id are not. They live in the browser, and the only way the server learns them is if the browser sent them, either in the request that triggered the event or earlier in a way the server stored. Skip this and the event attaches to the right user but floats off their session, which quietly breaks every session-based analysis you'll later try to run.
The second issue is enrichment. None of the context that client SDKs capture for free is available to the server. Page URL is not in the request. Referrer is not in the request. Device type and browser are sometimes in the User-Agent header and sometimes not. Anything you want on the event has to be assembled and passed in.
The third is engineering cost. You're maintaining two tracking surfaces. A change to an event name or a property has to propagate to both. Teams that don't standardize the schema end up with the same event sent two different ways from two places, and dashboards that disagree with each other.
What server-side gives you
If your code runs, the event ships. No browser to fail. No ad blocker. No flaky home wi-fi. For anything where the number has to be right, this is the only acceptable choice. Revenue is the obvious one. Conversion rates, churn signals, and anything tied to billing follow the same logic.
The call also comes from your infrastructure to the analytics provider's API, which means the user cannot block it, replay it, or fake it. For events you intend to act on, like sending a welcome email or triggering a sales workflow, that matters.
There's an authority point too. The server is where the database write actually happened. The "Project created" event from the browser only confirms the user clicked the button. From the server, it confirms a row was actually written. Most teams want the second version, which is the topic of Tracking on successful states.
When server-side is the only option
Some events have no client-side option at all. Anything that happens without the user present has to come from the server, because there is no browser to fire from.
Subscription renewals are the canonical example. The user signed up three months ago. Their card is charged automatically at 2 a.m. on a Tuesday. They are asleep. Their browser is closed. The "Subscription renewed" event can only come from the code that processes the renewal, which is your server reacting to the Stripe webhook.
The same logic covers a long list of events:
- Trial-to-paid conversions when the trial expires on its own
- Failed payment retries and dunning flows
- Plan downgrades that take effect at the end of the billing period
- Scheduled exports, reports, and digest emails
- Cron-triggered cleanups and recalculations
- Webhook events from payment providers, calendar tools, and other integrations
- Async background jobs that finish minutes or hours after the user kicked them off
- Anything fired by another system on your user's behalf, without them in front of a screen
Anything in this category fails silently if you try to track it client-side. The browser is not there to fail loudly. The event simply never exists, and the gap in the data only shows up later, when someone notices that monthly revenue numbers don't match Stripe.
Why you usually need both
A pure client-side setup throws away the events that matter most, because revenue and renewals can only come from the server. A pure server-side setup throws away the context that makes the rest of the data useful, because page paths, devices, referrers, and sessions live in the browser.
The hybrid is the default. The basic module and the feature module are mostly client-side, because most of what they capture is UI interaction with the user present. The revenue module is entirely server-side. The auth module can go either way, and server-side is the safer choice for signup and login, because those events are what conversion analyses are built on.
A rough split looks like this:
| Event type | Where | Why |
|---|---|---|
| Page views, navigation, UI interactions | Client | Auto-context, user is present, some loss is tolerable |
| Feature actions (created, updated, deleted) | Client, after the server confirms | Granular context plus authoritative state |
| Sign-up, sign-in, sign-out | Server preferred, client acceptable | Conversion analyses need accuracy |
| Subscription started, trial converted, renewed, churned | Server only | The browser isn't present when most of these fire |
| Webhooks, cron jobs, async work | Server only | No browser to fire from |
The split follows a simple rule. Server-side handles anything where the number has to be accurate. Client-side handles anything where the free context is what makes the event worth recording.
The stitching problem
Mixing the two creates one specific gotcha. The events have to land in the same user's timeline, in the right order, attributed to the same session. That doesn't happen on its own.
A user clicks "Start trial" in the browser. The client-side event fires immediately with the user's user_id, device_id, and session_id. Thirty seconds later Stripe sends your server a webhook confirming the subscription. Your server fires "Subscription started." For both events to sit in the same session in Amplitude, the server event has to carry the same device_id and session_id the browser used. The browser has them. The server learns them by reading them off the request that started the trial, or by storing them against the user when the session began.
Skip this and the server-side event still attaches to the right user, because the user_id is correct. It just floats off the browsing session. The user appears in user-level reports normally and disappears from session-level reports as if half their activity happened on a separate visit. The full mechanics are in Authentication tracking and Stripe revenue tracking.
The cost is small when you plan for it from the start. Teams that discover it six months later spend weeks repairing session data that should have been correct the first time.