Measure in the browser what happens on the screen, and on your server what your server decides. Only the browser sees screen size, engagement, scroll depth, web vitals, client-side route changes and clicks; only your server knows that a signup succeeded, that a payment cleared, what your API and background jobs did, and which visitors and crawlers never ran your script.
Most sites need both, and the hard part is tying a server event to the visit that caused it: with an id the browser stored (GA4, PostHog, Mixpanel) or with the visitor’s IP address and user agent (Plausible, Umami, and invisit, the cookieless analytics tool we build). Below, as of November 2026: the split, each vendor’s server API with a request we ran where we could, and when log files beat both.
Which events to measure in the browser and which on the server
Send each event from the place where the thing it describes becomes true.
| What you want to know | Measure it in | Why |
|---|---|---|
| Screen size, engagement time, scroll depth | Browser | Only JavaScript in the page can read them |
| Web vitals (LCP, INP, CLS) | Browser | The browser measures them while the page runs |
| Client-side route changes | Browser | Your server sees a data fetch at most |
| Clicks, outbound links, downloads | Browser | A click to another site never reaches your server |
| Source, UTM tags, browser, country | Either | The request’s URL, headers and IP address carry them |
| A signup that succeeded | Server | Only the code that inserted the row knows |
| A payment | Server, by webhook | The payment provider tells your server, not the browser |
| API calls and background jobs | Server | No browser is involved |
| Visitors whose blocker stops your script | Server | The page request still arrives |
| Crawlers and status codes | Server | Crawlers rarely run JavaScript; statuses are set server-side |
Our Next.js guide applies this rule to a signup form and a Stripe webhook line by line.
What can only the browser measure?
A server can read a browser, OS, device type and country from the request. It cannot see the window size, how long the tab stayed visible, how far the visitor scrolled or what they clicked.
Web vitals are the clearest case: LCP, INP and CLS are measured by the browser itself, so a server-only setup has none from real visitors (real-user web vitals). Client-side navigation is the other. A single-page app changes the address with the History API, and your server sees nothing, or a data fetch it cannot tell from a prefetch, which is why SPA page views need a tracker that follows the router.
What can only your server measure?
Your server sees outcomes, and traffic with no browser in it.
- Outcomes. A submit means someone clicked; only your database knows the account exists, and a payment is confirmed to your webhook, sometimes days later.
- Traffic without a page. API clients, cron jobs and queues run no script. For per-user product and API usage, PostHog and Mixpanel fit better than page-centric web analytics.
- Visitors your script never reached. Plausible puts the loss to blockers at “typically between 5% and 25%” without a proxy (Plausible); our proxy guide measures yours.
- Crawlers and status codes. See AI crawlers and tracking 404 pages.
How do you tie a server event to the visit that caused it?
Pass along something that identifies the visitor’s browser: an id its script stored, or the IP address and user agent from the request their browser sent you. Without either, the event carries your server’s address and user agent, and joins no visit or is dropped as a bot.
| Tool | What ties the server event to the visit | Join window | What breaks it |
|---|---|---|---|
| GA4 | client_id and session_id from its cookies |
Within 24 hours of the session start | No cookie: blocked, declined or cleared |
| PostHog | distinct_id and $session_id |
Sessions are grouped by $session_id |
Server SDKs send no $session_id by default |
| Mixpanel | $device_id or $user_id |
Sessions computed at query time, 30 minutes idle | An id the browser never used |
| Plausible | The visitor’s User-Agent and IP address |
30 minutes after the visitor’s last event | Your server’s address; a changed IP |
| Umami | ip and userAgent in the payload |
The clock hour the browser’s visit began, in our test | Adding an id, which starts a new session |
| invisit | The visitor’s request | 30 minutes idle, same UTC day | Your server’s user agent; a changed IP |
An id survives a change of address, such as a phone leaving Wi-Fi, or a visitor who reaches the analytics host over IPv6 while your server sees IPv4. It needs storage in the browser, which is why GA4, PostHog and Mixpanel set a cookie by default (cookie banners).
An address join needs no storage, so it is how server-side tracking works without cookies. It needs the visitor’s own request, or their address and user agent kept from it, inside a time window. A Stripe webhook carries Stripe’s address, so keep the visitor’s at checkout (the code), and serve the tracker through a first-party proxy so browser and server share one address.
Server-side analytics APIs compared
All six take events over HTTPS and differ in authentication, backdating and how they report a bad request. As of November 2026, from each vendor’s docs:
| Tool | Endpoint | Authentication | Backdating | Per request | When a request is wrong |
|---|---|---|---|---|---|
| GA4 | POST /mp/collect |
api_secret, a secret |
Up to 72 hours | 25 events, under 130 kB | Always 2xx; test on /debug/mp/collect |
| Plausible | POST /api/event |
None: the domain |
No timestamp field documented | 1 event | Always 202; x-plausible-dropped: 1 |
| Umami | POST /api/send, /api/batch |
None: the website id | timestamp, in the source only |
1, or an array | {"beep":"boop"} for a bot user agent |
| PostHog | POST /i/v0/e/, /batch/ |
Project token, public | timestamp, ISO 8601 |
Batch under 20 MB | 200, even with a made-up token in our test |
| Mixpanel | POST /import, /track |
Project token as basic-auth user | /track 5 days; /import back to 1971 |
2,000 events, 10 MB | strict=1 gives 400 listing failed records |
| invisit | track() in @invisitdev/server |
Ingest-only secret key | None: dated on arrival | 1 event | Rejects with InvisitError; bots get accepted: 0 |
Sources: GA4 (sending, validation), Plausible, Umami (docs, source), PostHog, Mixpanel (import, Node SDK), invisit.
How to send events with the GA4 Measurement Protocol
POST JSON to https://www.google-analytics.com/mp/collect with your measurement ID and an API secret in the query string, and the client_id the Google tag set in the visitor’s browser. Create the secret under Admin, Data streams, your stream, Measurement Protocol API secrets, and keep it on the server: Google warns that anyone holding it can send spam into your property.
curl -X POST "https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=$GA_API_SECRET" \
-H 'Content-Type: application/json' \
-d '{
"client_id": "1234567890.1793700000",
"validation_behavior": "ENFORCE_RECOMMENDATIONS",
"events": [{
"name": "sign_up",
"params": { "method": "email", "session_id": 1793700000, "engagement_time_msec": 100 }
}]
}'This goes to the validation server, which answers {"validationMessages": []} when the payload is right; drop /debug and validation_behavior to send for real. Read both ids in the browser with gtag('get', 'G-XXXXXXXXXX', 'client_id', callback) and the same call for 'session_id' (gtag reference). They live in the _ga and _ga_<container-id> cookies (Google), so the join depends on the cookie and on whatever consent gates it.
We ran this on November 3, 2026 with a made-up measurement ID and secret, which the validation server does not check: no messages. session_start as the name returned NAME_RESERVED, no client_id returned VALUE_REQUIRED, and the real endpoint answered a broken payload with 204.
What the Measurement Protocol cannot do, per Google:
- Replace the tag. Server events alone give “only partial reporting”, and GA4’s rules for creating or renaming events skip them.
- Arrive late. Timestamps older than 72 hours are moved to 72 hours ago, or rejected under
ENFORCE_RECOMMENDATIONS; session attribution needs the request within 24 hours of the session start. - Use reserved names such as
session_start,first_visitanduser_engagement. - Know the device. Without tag data it falls back to
desktop; senddevice, andip_overrideoruser_location.
Google now points new server-to-server integrations to its Data Manager API, though the Measurement Protocol has no deprecation planned.
How to send server-side events to Plausible
POST to https://plausible.io/api/event with the visitor’s User-Agent header, their IP address in X-Forwarded-For, and a body with name, url and domain. There is no key, so anyone who knows your domain can send events to it.
curl -i -X POST https://plausible.io/api/event \
-H "User-Agent: $VISITOR_USER_AGENT" \
-H "X-Forwarded-For: $VISITOR_IP" \
-H 'Content-Type: application/json' \
-d '{"name":"Signup","url":"https://example.com/signup","domain":"example.com","props":{"method":"email"}}'Those two headers are Plausible’s visitor, hashed with a daily salt and the domain (data policy). A server event joins a visit only with that visitor’s user agent and IP, a url on the same site, and within 30 minutes of their last event; otherwise it starts a new visit credited to Direct / None. With your server’s or CDN’s address, bot filtering drops it and still answers 202. Plausible therefore says to send a purchase from the request that renders your thank-you page, not from a payment webhook.
X-Debug-Request: true returns the IP Plausible would use and records nothing. We sent the request above with it on November 3, 2026 and got back the address from our X-Forwarded-For; a test event for a domain with no Plausible site came back 202 with x-plausible-dropped: 1.
How to send events to Umami from your server
POST to /api/send on your instance, or https://gateway.umami.is/api/send on Umami Cloud, with your website id and no token. Umami’s docs ask for a real User-Agent header and say nothing about the visitor’s address. Its source does: version 3.4.1 accepts the visitor’s ip and userAgent in the payload, and a timestamp in seconds.
curl -X POST https://umami.example.com/api/send \
-H 'User-Agent: Mozilla/5.0 (compatible; MyServer/1.0)' \
-H 'Content-Type: application/json' \
-d '{
"type": "event",
"payload": {
"website": "<website id>",
"hostname": "example.com",
"url": "/signup",
"name": "signup_completed",
"data": { "method": "email" },
"ip": "203.0.113.7",
"userAgent": "<the visitor user agent>"
}
}'We ran self-hosted Umami 3.4.1 in Docker on November 3, 2026, sent a page view as a browser would, then server events:
- With the visitor’s
ipanduserAgent, the server event got the page view’s session and visit id. - Adding
id, Umami’s distinct ID, gave it a new session and visit; the source keeps identified events apart on purpose. - A visit id derives from the session and the clock hour the visit began, and the browser carries its visit across the hour with a cache token your server lacks. Timestamped page views at 17:55 and 18:03 shared a visit; a server event at 18:05 started a new one.
- A user agent of
nodeoraxios/1.7.0got{"beep":"boop"}and was not stored.
How to send server-side events to PostHog
POST to https://us.i.posthog.com/i/v0/e/, or eu.i.posthog.com, with your project token, an event name and a distinct_id, or call capture() in posthog-node. The token is public by design.
curl -X POST https://eu.i.posthog.com/i/v0/e/ \
-H 'Content-Type: application/json' \
-d '{
"api_key": "<project token>",
"event": "user signed up",
"distinct_id": "<X-POSTHOG-DISTINCT-ID from the visitor request>",
"properties": { "$session_id": "<X-POSTHOG-SESSION-ID>", "method": "email" }
}'PostHog joins by id, never by address. Backend events “need a distinct_id that matches the ID your frontend uses” (Node docs), and server SDKs send no $session_id unless you set it (sessions). With tracing_headers: ['api.example.com'] in posthog-js 1.380.0 or later (config), every fetch to that host carries both headers above, and posthog-node’s setupExpressRequestContext reads them in Express.
Since posthog-node 3.0, server events get no GeoIP properties by default, because they would place everyone at your server. A made-up project token still got 200 {"status":"Ok"} in our test on November 3, 2026, so check that the event shows up.
How to send server events to Mixpanel
POST an array to https://api.mixpanel.com/import?strict=1 with your project token as the basic-auth user name, each event carrying time, distinct_id and $insert_id. The Node SDK’s track() uses /track, which ingests only the last 5 days; /import takes older events, up to 2,000 and 10 MB per request.
curl -X POST 'https://api.mixpanel.com/import?strict=1' \
-u "$MIXPANEL_TOKEN:" \
-H 'Content-Type: application/json' \
-d '[{
"event": "Signed Up",
"properties": {
"time": 1793700040000,
"distinct_id": "user_42",
"$user_id": "user_42",
"$device_id": "<device id from the Mixpanel browser SDK>",
"$insert_id": "signup-42",
"ip": "203.0.113.7",
"$mp_raw_user_agent": "<the visitor user agent>"
}
}]'Mixpanel has no visit to join on arrival: it computes sessions when you query, after 30 minutes of inactivity by default (sessions), so an event with the right id and time lands in the right session even when imported later. Under Simplified ID Merge, an event with both $user_id and the browser’s $device_id links the anonymous history to the user (identity).
Mixpanel does not parse server events’ user agents; its server-side guide has you parse them and forward the raw header as $mp_raw_user_agent. Without ip, every event is located at your server. A made-up token got a clear 401.
How to send server-side events with invisit
invisit takes server events through track() in @invisitdev/server, an MIT-licensed SDK for Node 20 or later, Bun, Deno, Cloudflare Workers and edge runtimes. Pass the visitor’s own request, and the SDK sends their address, user agent, Accept-Language and Sec-GPC with an ingest-only secret key.
import { createInvisit } from '@invisitdev/server'
import { createAccount } from './accounts' // your own code
const invisit = createInvisit({ key: process.env.INVISIT_API_KEY! })
Bun.serve({
async fetch(request) {
// Serves the tracker under /_i, so browser and server events reach invisit from one address.
const proxied = await invisit.proxy(request)
if (proxied) return proxied
if (new URL(request.url).pathname === '/api/signup' && request.method === 'POST') {
const user = await createAccount(request)
if (!user) return Response.json({ error: 'signup_failed' }, { status: 400 })
await invisit
.track('signup_completed', { request, userId: user.id, properties: { method: 'email' } })
.catch(console.error) // a lost event must never fail the signup
return Response.json({ id: user.id }, { status: 201 })
}
return new Response('Not found', { status: 404 })
},
})The page loads the tracker from /_i/invisit.js with data-collector="/_i" (proxy setup). The collector hashes the server event’s address and browser with the site and the UTC date, as it does for a page view, and adds the event to the visit that hash had within the last 30 minutes. First it checks the key, per-minute limits per address and per visitor, and the user agent against the same bot rules as browser events, which treat curl, node-fetch, axios and undici as bots. A bot or a Sec-GPC: 1 visitor gets { accepted: 0 }, and nothing is stored or billed.
A server event is a custom event and billed like one. track() has no timestamp option, so send the event when it happens. For a visitor whose blocker stopped the script, the event starts a visit with no page view: it counts in the Events report but not in visit totals, which need a page view.
Where invisit is weaker: the SDK is JavaScript only, with no documented HTTP endpoint for other languages; track() sends one event at a time and cannot backdate it; and every event needs a visitor’s address and browser user agent, so cron jobs and API metering have nothing to send (reference).
We ran @invisitdev/server 0.3.0 against our development stack on November 3, 2026. A Chromium page view through the proxy and a server-sent signup_completed landed in one visit, and the signup showed in the Events report filtered to the page view’s utm_source. A node-fetch/1.0 user agent and a gpc: true visitor returned { accepted: 0 }; a repeated eventId returned { duplicate: 1 }.
When is log-based analytics the right choice?
Log analysis is right when the question is about requests: which crawlers read your pages, what returned 404, what your API served, how much traffic blockers hide. It is wrong for questions about people: nothing measured in the page reaches a log.
goaccess /var/log/nginx/access.log --log-format=COMBINED --ignore-crawlers --anonymize-ip -o report.htmlGoAccess counts a unique visitor as requests with the same IP, date and user agent: a cookieless hash without the salt. --crawlers-only inverts the report, and --anonymize-ip zeroes the last octet of IPv4 addresses. We ran GoAccess 1.12 on a five-line log on November 3, 2026: --ignore-crawlers kept 3 requests from 2 visitors, and --crawlers-only showed the Googlebot and GPTBot lines.
| Logs are right for | Logs are wrong for |
|---|---|
| Crawler and AI bot reads | Client-side route changes and CDN-cached pages |
| Visitors who block or never run scripts | Human counts, with bots and prefetches mixed in |
| Status codes, bandwidth and API traffic | Engagement, scroll depth, web vitals and clicks |
Logs keep full IP addresses unless anonymized. Why analytics numbers differ reconciles log and JavaScript counts.
Before you send your first server event
The trap to avoid is trusting the status code: GA4, Plausible and PostHog all answered success to events they would never record.
- Every event has one sender, browser or server; a signup sent from both counts and bills twice.
- Server events carry the visitor’s id or their own address and user agent, never your server’s.
- Webhook events use visitor details kept at checkout, or skip the visit join on purpose.
- One request went through the vendor’s debug tool, and the event appeared in the dashboard.
- Retries reuse an idempotency key where there is one: Mixpanel’s
$insert_id, invisit’seventId. - GA4’s
api_secretand invisit’s key live only in server-side variables; Plausible’s domain, Umami’s website id and PostHog’s token are public, so anyone can send with them. - If you honour Global Privacy Control in the browser, your server events honour it too.
