A client wanted to know who was on their site and on which page, the way Tidio's widget showed them — but without paying for a Tidio subscription, on an external WordPress site that doesn't use Firebase. The result: an external tracker hooked to an independent Firebase project, live presence via onDisconnect, persistent history in Firestore with IP geolocation — and a final debugging session where a browser CORS error was masking a server crash caused by an empty string instead of null.
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.
// Every visitor gets an anonymous session id (localStorage)
// and writes under sitePresence/{sessionId}
const ref = db.ref(`sitePresence/${sessionId}`);
// What to write when the connection drops, registered IMMEDIATELY
ref.onDisconnect().remove();
// Only after that, the "online" data with current page
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:
<script src="https://[client-panel].example/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.
{
"rules": {
"sitePresence": {
".read": "auth != null && auth.uid === '<ADMIN_UID>'",
"$sid": { ".write": "auth != null" }
}
}
}
Granularity note: with this rule, an anonymous session could in theory overwrite another anonymous session's node, since the id is client-generated and not checked against
auth.uid. For presence/analytics-only data — no sensitive data involved — that's an acceptable trade-off; to lock it down fully, save theauth.uidin the record and check it in the write rule.
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.
// Written ONLY by the Netlify Function via service account,
// which bypasses these rules anyway: write:false is defense in depth.
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:
[[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:
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:
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.
// Before — crashes: empty body not allowed on status 204
return new Response('', { status: 204, headers: corsHeaders });
// After — explicitly null body
return new Response(null, { status: 204, headers: corsHeaders });
Operational lesson: a
curl -X POSTtest doesn't prove a browser cross-origin call works. To really verify a browser-facing endpoint, the preflight must be replicated explicitly:curl -X OPTIONS ... -H "Origin: ..." -H "Access-Control-Request-Method: POST".
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.
| Symptom | Cause |
|---|---|
| History stuck at zero sessions | One-year immutable cache on the tracker: visitors were still downloading the old script version |
| "No Access-Control-Allow-Origin" in the browser | Crash in the Response constructor: empty-string body on status 204 instead of null
|
| Today/7d/30d filters empty, "Unknown" geo | Fields computed only on the start event, never on update
|
What I'm taking away
- 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.
- 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.
-
A
curl -X POSTtest 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. -
"No content" HTTP statuses require an explicitly
nullbody. 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. - 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.
This post was originally published on roversia.it, where I write about the vanilla JS/Firebase/Netlify stack behind my projects.
Top comments (0)