Skip to main content

Live "online users" on a static blog: GA4 Realtime + a Cloudflare Worker

· 10 min read
Charles Wang
DevOps / Backend Engineer

I wanted the homepage stats bar to show a current online users number, next to the build-time stats (posts, tags, days since last post). It sounds like a one-liner, but a static site fundamentally cannot call the GA4 Realtime API directly — the API needs a service-account private key, and anything you ship to the browser is public. This post is the whole path I took: the build-time-vs-runtime split, why a proxy is mandatory, the Cloudflare Worker that signs its own Google token, wiring the frontend, and the three traps I fell into along the way.

The realization: build-time vs runtime

The stats bar already had four numbers, and they were all computed at build time by reading the repo:

  • posts / tags — counted from blog frontmatter while Docusaurus builds.
  • last post — diff between today and the newest post date.

Those are static: bake them into the HTML and they're correct until the next build. A visitor count is the opposite — it only exists at runtime, on a server that's recording traffic. The site itself records nothing. So any "visitors" or "online" number needs an external analytics source queried from the browser. That single distinction drove the whole design.

Why you can't call GA Realtime from the browser

GA4's realtime data comes from the Analytics Data API (properties/{id}:runRealtimeReport, metric activeUsers). To call it you must authenticate as a service account — which means holding its private key and signing a JWT to exchange for an access token.

A static site's JavaScript is fully visible to anyone. Put that key in the bundle and you've handed out read access to your analytics. So the only safe shape is a tiny server in the middle:

browser (stats bar) ──► Cloudflare Worker ──► GA4 Realtime API
(holds the key,
caches ~60s,
returns { "active": N })

The Worker is the only thing that ever sees the key. The browser only ever talks to the Worker, which returns a harmless { "active": N } with permissive CORS.

note

GA's "active users" means users active in the last 30 minutes — the exact number GA's Realtime report shows. It's the industry-standard "online now," not a literal "looking at the page this very second."

Step 1 — page tracking (the easy, SEO part)

This is the part most people mean by "add Google Analytics," and it's trivial. Docusaurus' classic preset has a built-in gtag plugin that also handles client-side route changes:

// docusaurus.config.ts
const GA_MEASUREMENT_ID = 'G-XXXXXXXXXX';

presets: [
['classic', {
// ...
...(GA_MEASUREMENT_ID
? {gtag: {trackingID: GA_MEASUREMENT_ID, anonymizeIP: true}}
: {}),
}],
],

This alone gives you SEO/traffic analytics. It does not give you the realtime number — that's Steps 2–4.

note

GA4 shows you three different IDs and it's easy to grab the wrong one:

  • Measurement ID (G-XXXXXXXXXX) — for gtag page tracking (this step).
  • Stream ID (a long number on the data-stream page) — not what the API wants.
  • Property ID (a number under Admin → Property settings) — this is what the Realtime API call below needs.

Step 2 — service account + Data API

  1. In Google Cloud Console, enable the Google Analytics Data API.
  2. Create a service account, then create and download a JSON key.
  3. In GA Admin → Property access management, add the service account's client_email as a Viewer on the property. (Skipping this is Pitfall 3 below.)

Step 3 — the Worker: signing a Google JWT with WebCrypto

Cloudflare Workers have no googleapis SDK, but they do have WebCrypto, so you build and sign the JWT yourself. The flow: make a JWT claiming the service-account identity, sign it RS256 with the private key, exchange it for an access token, then call the Realtime report. The interesting bits:

async function getAccessToken(env) {
const now = Math.floor(Date.now() / 1000);
const header = {alg: 'RS256', typ: 'JWT'};
const claim = {
iss: env.GA_CLIENT_EMAIL,
scope: 'https://www.googleapis.com/auth/analytics.readonly',
aud: 'https://oauth2.googleapis.com/token',
iat: now, exp: now + 3600,
};
const unsigned = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claim))}`;

const key = await importPrivateKey(env.GA_PRIVATE_KEY); // PKCS8 PEM -> CryptoKey
const sig = await crypto.subtle.sign(
{name: 'RSASSA-PKCS1-v1_5'}, key,
new TextEncoder().encode(unsigned),
);
const jwt = `${unsigned}.${b64urlBytes(new Uint8Array(sig))}`;

const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {'content-type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
}),
});
return (await res.json()).access_token;
}

Two details that bite:

  • The private key from the JSON is PKCS8 PEM. Strip the -----BEGIN/END----- lines and whitespace, base64-decode to bytes, then crypto.subtle.importKey('pkcs8', ...). Also normalize \n → real newlines, because secrets are often stored with escaped newlines.
  • Cache both the access token (~55 min) and the active-users result (~60 s) in module scope. Otherwise every page load hits Google and you'll burn through quota.

The actual report call is small:

const res = await fetch(
`https://analyticsdata.googleapis.com/v1beta/properties/${env.GA_PROPERTY_ID}:runRealtimeReport`,
{method: 'POST',
headers: {authorization: `Bearer ${token}`, 'content-type': 'application/json'},
body: JSON.stringify({metrics: [{name: 'activeUsers'}]})},
);
const data = await res.json();
const active = Number(data?.rows?.[0]?.metricValues?.[0]?.value ?? 0);

Secrets are set with Wrangler (note the names must match env.X in the code):

npx wrangler secret put GA_CLIENT_EMAIL # paste the client_email
npx wrangler secret put GA_PRIVATE_KEY # paste the private_key value
npx wrangler deploy

GA_PROPERTY_ID and ALLOWED_ORIGIN are non-secret, so they live in wrangler.toml.

Step 4 — wire the frontend (poll every 60s)

The homepage reads the Worker URL from config and polls it after hydration. During the static build there's no fetch, so it renders a placeholder, then fills in on the client:

function useOnline(apiUrl: string): string {
const [value, setValue] = useState('···');
useEffect(() => {
if (!apiUrl) { setValue('—'); return; }
let alive = true;
const load = () =>
fetch(apiUrl)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d) => alive && setValue(String(d.active ?? '—')))
.catch(() => alive && setValue('—'));
load();
const id = setInterval(load, 60_000);
return () => { alive = false; clearInterval(id); };
}, [apiUrl]);
return value;
}

A failed/unconfigured fetch falls back to , so the page never shows a broken stat.

Step 5 — serve it from your own domain (optional, but cleaner)

The Worker deploys to a *.workers.dev URL by default. If your domain's DNS is already managed by Cloudflare, you can serve it from your own subdomain instead — nicer to read, not tied to the workers.dev subdomain, and less likely to be tripped up by privacy extensions or networks that block *.workers.dev.

Add a custom-domain route to wrangler.toml and redeploy:

[[routes]]
pattern = "ga4-proxy.forklore.dev"
custom_domain = true
npx wrangler deploy

wrangler deploy provisions the DNS record and the TLS certificate automatically (the cert takes a minute or two). Then point the frontend at the new URL:

const ONLINE_API_URL = 'https://ga4-proxy.forklore.dev';
curl https://ga4-proxy.forklore.dev # {"active":0}
note

ALLOWED_ORIGIN does not change — it stays https://forklore.dev. The Worker's own hostname (where it's served) and the caller's origin (who's allowed to call it, the CORS setting) are two different things. A custom domain only needs the zone to be on Cloudflare; it works even if the blog itself is hosted elsewhere.

Folding in the visitor count (and dropping GoatCounter)

The realtime "online" number is only half the stats bar — I also wanted an all-time unique visitors count. My first version used GoatCounter: cookieless, zero-config, a one-line script plus a public /counter/TOTAL.json endpoint the page could fetch directly — no proxy needed. It worked.

Then I deleted it. The reasoning:

  • GA was already in the page for SEO/analytics, and the Worker already existed for the realtime number. GoatCounter's headline advantage is being cookieless / privacy-first — but that edge evaporates the moment you're already running GA. I wasn't actually buying the one thing GoatCounter is best at.
  • Keeping it meant two analytics sources to reconcile and a second third-party script on every page, for a number GA can already give me.

So I consolidated onto the Worker. GA's all-time count comes from a standard report (not realtime), so the Worker makes a second call and returns both numbers:

// alongside the realtime activeUsers call, a standard report for all-time users:
const data = await gaReport(env, token, 'runReport', {
dateRanges: [{startDate: '2020-01-01', endDate: 'today'}],
metrics: [{name: 'totalUsers'}],
});
const total = Number(data?.rows?.[0]?.metricValues?.[0]?.value ?? 0);
// the Worker response becomes: { active, total }

The frontend now reads active (online) and total (visitors) from a single fetch, and the GoatCounter script and account are gone.

  • Concept: don't run two tools for one job unless each earns its place. GoatCounter would have earned it if I weren't already on GA — cookieless analytics is a real, distinct value. Here it was redundant, and a redundant dependency is pure maintenance cost. The cheapest integration is the one you don't keep.

The pitfalls (the part that actually cost time)

Pitfall 1: npm audit fix --force wrecked the toolchain

npm install for Wrangler succeeded, but it reported a few "vulnerabilities," so I reflexively ran npm audit fix --force. That made a major-version change and ended up downgrading Wrangler to a very old release, which dragged in an old better-sqlite3 that has no prebuilt binary for Node 24 — so it tried to compile from C++ source and exploded with C++20 or later required.

  • Cause: --force is allowed to make breaking version changes to silence advisories.
  • Fix: rm -rf node_modules package-lock.json, pin a current wrangler (^4), reinstall, and don't run audit fix here.
  • Concept: an npm audit advisory describes a risk for some usage of a package. The ones here were in esbuild's dev server — code that never ships to the deployed Worker. The advisory being real doesn't mean my path is exposed. --force "fixing" a non-applicable advisory did far more damage than the advisory ever could.

Pitfall 2: wrangler secret put takes the NAME, not the value

I ran wrangler secret put [email protected], pasted a value at the prompt, and "succeeded" — having created a secret whose name was the email.

  • Cause: the argument to secret put is the secret's key; the value is read from the interactive prompt afterward.
  • Fix: wrangler secret delete "<the wrong name>", then wrangler secret put GA_CLIENT_EMAIL and paste the email at the prompt.
  • Concept: the secret name must exactly match what the code reads (env.GA_CLIENT_EMAIL). A secret with the right value but the wrong name is invisible to the Worker.

Pitfall 3: GA 403 PERMISSION_DENIED

First real request to the Worker returned:

{"active": null,
"error": "GA API 403: ...User does not have sufficient permissions for this property..."}
  • Cause: the JWT, token exchange, and the API call all worked — the service account simply hadn't been granted access to the GA property yet.
  • Fix: GA Admin → Property access management → add the service account's client_email as Viewer, wait a minute, retry.
  • Concept: a 403 here is reassuring — it means auth succeeded and you reached the property; it's purely an authorization (access-grant) gap, not a broken key. Read the error status: PERMISSION_DENIED (no access) is a different problem from UNAUTHENTICATED (bad token) or SERVICE_DISABLED (API not enabled).

Verify

Curl the Worker (or just open it). Once the service account has access you should get:

curl https://<your-worker>.workers.dev
# {"active":0,"total":0}

0 is correct when nobody's currently on the site — null would mean something upstream is still wrong. A real number proves the whole chain.

Summary

PieceWhen it runsWhere it's configuredSecret?
posts / tags / last postbuild timecomputed from blog frontmatterno
page tracking (gtag)runtimegtag preset option (Measurement ID)no
online + visitorsruntimeWorker (runRealtimeReport + runReport)
Property ID / Measurement IDwrangler.toml / docusaurus.config.tsno
service-account keyruntimeCloudflare secret (GA_PRIVATE_KEY)yes

The mental model worth keeping: static numbers get baked at build; live numbers need a runtime source, and any source that requires a private key needs a proxy in front of it. The proxy is small, but it's the whole reason the secret never leaks.