[invisit](https://invisit.dev) · [Docs](https://invisit.dev/docs) · [llms.txt](https://invisit.dev/llms.txt) · [llms-full.txt](https://invisit.dev/llms-full.txt)

# Server-side vs client-side analytics: what to measure where

> What only the browser or only your server can see, how a server event joins the visit, and tested server APIs for GA4, Plausible, Umami, PostHog and Mixpanel.

- Author: Miraç Berk
- Published: 2026-11-03
- Page: https://invisit.dev/blog/server-side-analytics
- Tags: analytics, server-side, events, google-analytics

![A soft 3D browser window and a small server tower passing event cards along a dotted line, in violet dithered light and shadows.](https://invisit.dev/_astro/cover.Btoge-nA.png)

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](/blog/nextjs-analytics) 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](/blog/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](/blog/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](https://plausible.io/docs/proxy/introduction)); [our proxy guide](/blog/analytics-proxy) measures yours.
- **Crawlers and status codes.** See [AI crawlers](/blog/ai-crawlers) and [tracking 404 pages](/blog/track-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](/blog/cookie-banner-analytics)).

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](/blog/nextjs-analytics#how-to-track-stripe-payments-from-the-webhook)), 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](https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events), [validation](https://developers.google.com/analytics/devguides/collection/protocol/ga4/validating-events)), [Plausible](https://plausible.io/docs/events-api), Umami ([docs](https://docs.umami.is/docs/api/sending-stats), [source](https://github.com/umami-software/umami/blob/master/src/app/api/send/route.ts)), [PostHog](https://posthog.com/docs/api/capture), Mixpanel ([import](https://developer.mixpanel.com/reference/import-events), [Node SDK](https://docs.mixpanel.com/docs/tracking-methods/sdks/nodejs)), [invisit](https://invisit.dev/docs/server/sdk.md#server-side-events).

## 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.

```sh title="Terminal"
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](https://developers.google.com/tag-platform/gtagjs/reference)). They live in the `_ga` and `_ga_<container-id>` cookies ([Google](https://support.google.com/analytics/answer/11397207)), 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](https://developers.google.com/analytics/devguides/collection/protocol/ga4):

- **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](https://developers.google.com/analytics/devguides/collection/protocol/ga4/use-cases) needs the request within 24 hours of the session start.
- **Use reserved names** such as `session_start`, `first_visit` and `user_engagement`.
- **Know the device.** Without tag data it falls back to `desktop`; send `device`, and `ip_override` or `user_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.

```sh title="Terminal"
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](https://plausible.io/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.

```sh title="Terminal"
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:

1. With the visitor's `ip` and `userAgent`, the server event got the page view's session and visit id.
2. Adding `id`, Umami's distinct ID, gave it a new session and visit; the source keeps identified events apart on purpose.
3. 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.
4. A user agent of `node` or `axios/1.7.0` got `{"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.

```sh title="Terminal"
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](https://posthog.com/docs/libraries/node)), and server SDKs send no `$session_id` unless you set it ([sessions](https://posthog.com/docs/data/sessions)). With `tracing_headers: ['api.example.com']` in posthog-js 1.380.0 or later ([config](https://posthog.com/docs/libraries/js/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.

```sh title="Terminal"
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](https://docs.mixpanel.com/docs/features/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](https://docs.mixpanel.com/docs/tracking-methods/id-management/identifying-users-simplified)).

Mixpanel does not parse server events' user agents; its [server-side guide](https://docs.mixpanel.com/docs/tracking-best-practices/server-side-best-practices) 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.

```ts title="server.ts"
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](https://invisit.dev/docs/server/sdk.md#proxy)). 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](/blog/bot-traffic) 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](https://invisit.dev/docs/reference/server.md)).

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.

```sh title="Terminal"
goaccess /var/log/nginx/access.log --log-format=COMBINED --ignore-crawlers --anonymize-ip -o report.html
```

[GoAccess](https://goaccess.io/man) 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](/blog/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's `eventId`.
- [ ] GA4's `api_secret` and 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.
