The context

The client already had a third-party live-chat script installed on their site, and that's where the idea came from: "can we see who's on the site and on which page, without using that service?" Two constraints made the request less trivial than usual: the site hadn't been migrated to my usual stack yet — it was still running on WordPress, on different hosting — and there was no intention of introducing Firebase on the WordPress side.

Step 1 — understanding what a live-chat widget actually does

Before building anything, it was worth looking at what the already-installed script actually did. The tag pasted into the site was just a small loader: it creates a hidden iframe, loads the widget's real "brain" inside it from the provider's servers, which then connects via websocket to their backend to stream presence, current page and events in real time.

The interesting part — "see who's on the site and on which page" — isn't in the public script: it all lives server-side at the provider, behind authentication, a proprietary dashboard and a subscription. There was nothing to "detach" from that service: it's client code tied to someone else's backend by design. But the pattern itself — a script tag hooking into an external backend — is exactly what's needed to build the same feature independently, and it fits well with Firebase, which has a native presence mechanism built for precisely this.

Step 2 — live presence with Firebase Realtime Database

Firebase Realtime Database has a native presence pattern based on onDisconnect(): it registers a write or delete operation server-side to run automatically whenever the client's connection drops, no matter how — tab closed, network lost, browser crash — without the client having to do anything at that moment.

pattern di presenza, sessione anonima
// Ogni visitatore ottiene un id di sessione anonimo (localStorage)
// e scrive sotto sitePresence/{sessionId}
const ref = db.ref(`sitePresence/${sessionId}`);

// Cosa scrivere quando la connessione cade, registrato SUBITO
ref.onDisconnect().remove();

// Solo dopo, il dato "sono online" con pagina corrente
ref.set({
  page: location.pathname,
  referrer: document.referrer,
  userAgent: navigator.userAgent,
  lastSeen: firebase.database.ServerValue.TIMESTAMP
});

One detail worth noting: onDisconnect() must be registered before writing the "online" data, not after — otherwise there's a window where the client shows as online in the database but the server hasn't yet received instructions on what to do if it disconnects abruptly during that same window.

Step 3 — a tag on a site that isn't yours

The site stayed on WordPress, on different hosting, without touching the CMS's PHP or database. Just like the original live-chat widget, a single script tag pasted into the theme's header or footer (or via a plugin like "Insert Headers and Footers") loads a JavaScript file hosted elsewhere, which connects to a Firebase project completely external to the WordPress site:

tag sul sito WordPress
<script src="https://[pannello-cliente].it/site-tracker.js" async></script>

The tracker generates an anonymous session id (localStorage), signs in anonymously via Firebase Authentication, and writes presence, current page, referrer/UTM and user agent — with onDisconnect() for automatic cleanup. The WordPress site's domain doesn't need to be added anywhere on the Firebase side: Firebase Authentication's "Authorized domains" list only affects OAuth redirect/popup flows, while signInAnonymously() works from any origin.

Step 4 — the rules: who reads, who writes

With anonymous authentication already in use elsewhere in the Firebase project, what remained was correctly isolating the new sitePresence node: anonymous visitors must be able to write only their own session node, never read anyone else's; only the admin account should be able to read the full list.

Realtime Database rules — nodo sitePresence
{
  "rules": {
    "sitePresence": {
      ".read": "auth != null && auth.uid === '<ADMIN_UID>'",
      "$sid": { ".write": "auth != null" }
    }
  }
}
💡

Step 5 — persistent history and geolocation

Live presence answers "who's here now", but it isn't enough for statistics: data under sitePresence disappears on disconnect, by design. A durable history needs a different place — Firestore, with a dedicated collection written by a Netlify Function instead of the client, so the IP can also be geolocated server-side without exposing any key in the browser.

An honest note on geolocation: no free IP-geolocation service returns the exact Italian province — that's too fine-grained a detail for a technique that's mostly accurate at city/region level, sometimes only at ISP level. City and region are the best available without a dedicated paid service; province-level accuracy for major cities would need an extra lookup table.

Firestore security rules — collection siteVisits
// Scritta SOLO dalla Netlify Function tramite service account,
// che bypassa comunque queste regole: write:false è difesa in profondità.
match /siteVisits/{doc} {
  allow read: if request.auth != null;
  allow write: if false;
}

Obstacle #1 — the immutable cache hiding every update

After the first deploy, live presence worked — the client could see themselves navigating in real time — but Firestore history stayed at zero sessions. The main suspect: .js files served by Netlify carried Cache-Control: public, max-age=31536000, immutable by project convention, meant for internal assets versioned with ?v=. But the tag on the WordPress site pointed to a version-less URL — so on every tracker update, visitors kept downloading the old version for a full year, with no practical way to force a refresh short of asking the client to manually edit the tag every time.

The fix wasn't manually bumping a ?v= on every deploy — that would have meant a WordPress edit for every single change — but giving that specific file a much shorter cache policy, so it updates itself:

netlify.toml — eccezione per site-tracker.js
[[headers]]
  for = "/site-tracker.js"
  [headers.values]
    Cache-Control = "public, max-age=300, must-revalidate"

The general /*.js rule (one year, immutable) stays intact for all internally-versioned assets; only the externally-exposed tracker gets a five-minute cache. From that point on, every file update reaches visitors within minutes, with no need to ever touch the WordPress tag again.

Obstacle #2 — the CORS error that was actually a crash on the 204 response

With caching fixed, live presence kept working but history stayed empty. The browser showed an unambiguous error:

console del browser
Access to fetch at '.../site-visit-log' from origin '...' has been blocked
by CORS policy: Response to preflight request doesn't pass access control
check: No 'Access-Control-Allow-Origin' header is present on the requested
resource.

A direct test of the function via curl with a POST request, bypassing the browser, consistently returned 200 OK — which seemed to confirm a CORS domain-whitelist issue. But that test wasn't actually replicating what a browser does: before a cross-origin POST with Content-Type: application/json, the browser always issues a preflight OPTIONS request, which curl -X POST skips entirely. The "passing" test wasn't exercising the path that was actually broken.

Repeating the test with a real OPTIONS preflight, and checking the function's invocation logs on Netlify, revealed the real cause — an exception thrown before a response could even be built:

log Netlify Functions
TypeError: Response constructor: Invalid response status code 204
    at initializeResponse (node:internal/deps/undici/undici)
    at new Response (node:internal/deps/undici/undici)
    at Object.handler (site-visit-log.mjs:88)

The cause: the OPTIONS response was built with status 204 and a body set to an empty string ('') instead of null. The Fetch standard treats "no content" statuses — 204, 205, 304 — as incompatible with any body, even an empty one; Node.js, via the undici library that implements Response, enforces this strictly and throws in the constructor itself, before the response is even sent.

Hence the false lead: the function was crashing internally on every OPTIONS request, so Netlify returned a generic error with no headers. The browser, finding no Access-Control-Allow-Origin in the response, reported — correctly from its own point of view, but misleadingly about the cause — a CORS block. A server crash disguised as a domain-configuration problem.

prima / dopo
// Prima — crasha: body vuoto non ammesso su status 204
return new Response('', { status: 204, headers: corsHeaders });

// Dopo — body esplicitamente null
return new Response(null, { status: 204, headers: corsHeaders });
⚠️

Obstacle #3 — missing data because it was only written on the first event

With history finally populated, two more symptoms of the same underlying bug showed up at different times: the "Today / 7 days / 30 days" filters in the admin panel returned zero results even with real sessions already logged, and some sessions showed "Unknown" city/region despite geolocation working for others.

In both cases the cause was identical: a field — the first-seen timestamp and the geolocation data, respectively — was computed and written only on the start event (a session's first page load), never on subsequent update events. Sessions whose first event reaching the server was already an update — typical of visitors with a still-valid session in localStorage from before a deploy — stayed permanently without that field: date filters use it to include or exclude sessions, so they excluded all of them, while geolocation stayed empty forever.

The fix was the same in both cases: recompute and rewrite the field on every event, not just on start. An idempotent operation — the exact same value for the whole session lifetime — so there's no risk of overwriting a correct value, with the added benefit that already-incomplete sessions self-correct on their next event, with no manual intervention needed on old data.

When a custom tracking system makes sense (and when it doesn't)

When to use it

  • Widget to embed on third-party sites (a single script tag), where full control over behavior is needed with no dependency on an external SaaS tool's limits.
  • Privacy-first by design: data stays in your own Firebase instead of a third-party provider, relevant for strict GDPR constraints or not wanting to share traffic data with an external service.
  • Real-time live presence (who's online right now) beyond history, which many standard analytics tools don't offer natively without paid plans.
  • Moderate traffic volume, where Firebase costs (RTDB + history) stay under control compared to an enterprise tool.

When not to use it

  • Advanced standard analysis needed (funnels, cohorts, multi-channel attribution): Google Analytics or Plausible offer these features out of the box, which would cost far more time to rebuild from scratch than it's worth.
  • Very high traffic: RTDB with persistent history and geolocation for every event can become expensive or slow past a certain volume, without a dedicated aggregation architecture.
  • Team without time for ongoing maintenance: a managed SaaS tool eliminates the maintenance a custom system requires over time (caching, CORS, security rules).

Alternatives

For those who want to stay privacy-first without building from scratch, Plausible or Fathom offer cookie-free analytics with managed hosting, at a fixed monthly cost instead of custom development. For those who just want a simple visit count with no live presence, a lightweight counter based on a single Netlify Function is enough and cheaper.

Common mistakes

  • Not handling browser immutable caching on scripts embedded on third-party sites: an overly aggressive cache header hides every widget update until the client-side cache expires.
  • Confusing an apparent CORS error with a real crash: an HTTP 204 response with no body can generate an error that looks like CORS but is actually a client-side parsing issue.
  • Writing data only on a session's first event: if the user stays longer or navigates more pages, continuous state updates are needed, not just the initial snapshot.

What I'm taking away

  1. Analyzing a third-party widget before replacing it saves time. Realizing the public tag is just a loader tied to a proprietary backend immediately clarified that the goal was to rebuild an equivalent backend, not "detach" functionality from a script that doesn't contain any.
  2. A browser CORS error doesn't always mean a domain-whitelist problem. If the server crashes before building a response, the browser sees no CORS headers and reports a block — indistinguishable, client-side, from a misconfiguration. Server-side invocation logs are the source of truth, not the browser console.
  3. A curl -X POST test doesn't verify a browser-facing endpoint. Cross-origin requests with JSON go through an OPTIONS preflight first, which a plain curl POST skips entirely; a "passing" test can hide the real breaking point.
  4. "No content" HTTP statuses require an explicitly null body. An empty string isn't equivalent to no body for 204/205/304: Node.js/undici enforces this strictly and throws in the response constructor itself.
  5. Computing a value only on the "first" event assumes the server always sees that first event. Sessions already existing on a client can reach the server with an intermediate event as their actual first contact; recomputing derived fields on every event, idempotently, avoids permanent data gaps.

Frequently asked questions

How can you see who's online on a site without a third-party widget like Tidio?

A lightweight script that periodically writes the current page and an anonymous session id to a realtime database, plus a mechanism that removes the data on disconnect. Firebase Realtime Database offers this natively with onDisconnect(). An admin panel with a realtime listener shows the updated list, no third-party service needed.

Can you use Firebase on a site that isn't built with Firebase, like WordPress?

Yes, it's the same pattern as live-chat widgets: a script tag in the site's header or footer hooking into a Firebase project hosted elsewhere. The original site isn't touched at all — the logic lives entirely in the external JS file and the connected Firebase backend.

Why can a browser CORS error actually be hiding a server crash?

The browser first runs an OPTIONS preflight. If the code handling it errors out before responding, the server returns a generic error with no headers — and the browser, seeing no Access-Control-Allow-Origin, reports a CORS block. The real cause is in the invocation logs, not the CORS config.

Why must a 204 HTTP response body be null instead of an empty string?

The Fetch standard treats "no content" statuses — 204, 205, 304 — as incompatible with any body, even empty. Node.js, via undici, enforces this strictly and throws in the response constructor if you pass '' instead of null.

Why can saving a value only on a session's first event cause missing data?

If the client already had a locally saved session before a change shipped, the first event the server receives may be an intermediate update rather than the real opening event. If a field is written only on that opening event, those sessions stay without it forever. Recomputing it on every event, idempotently, avoids the problem.