The sendBeacon Gotcha That Silently Dropped Our Analytics
navigator.sendBeacon with an application/json Blob returned true, the browser quietly dropped the request, and our analytics chart went flat for two weeks.
We shipped a small client-side analytics path for SOLAI. A beacon on page unload, a JSON payload, an ingestion endpoint behind a CORS allow-list. Textbook. Two weeks later, the dashboard was almost empty and the boolean return value from navigator.sendBeacon had been cheerfully reporting true the entire time. Here is what we got wrong, how we caught it, and what we do now.
What we thought we'd built
The pattern is the one you will find in almost every MDN-era example. On visibility change or unload, package the event into a Blob with a JSON content type, hand it to sendBeacon, move on:
We constructed a Blob from a stringified event with the explicit MIME type application/json, called navigator.sendBeacon(url, blob), and trusted the boolean. The endpoint sat on a different origin from the marketing pages, with a tight Access-Control-Allow-Origin header and a POST handler that parsed the body as JSON. The handler logged. The handler counted. The count stayed at zero.
The return value lies
sendBeacon returns true when the user agent has successfully queued the data for transmission, and false when the queue is full or the payload exceeds the per-request limit. That is the entire contract. It is a queue receipt, not a delivery receipt. The browser tells you it accepted custody of your bytes; it tells you nothing about whether those bytes left the machine, crossed the network, passed CORS, or were ever parsed by your server.
For unload-time events, that asymmetry is the design. sendBeacon is fire-and-forget by definition — there is no callback, no promise, no second chance. So the boolean is the only synchronous signal you get, and it is, by construction, the wrong signal to lean on for correctness.
Why the request disappeared
application/json is not on the CORS list of "simple" content types. The simple set is application/x-www-form-urlencoded, multipart/form-data, and text/plain. A cross-origin POST with a non-simple Content-Type is supposed to be preceded by an OPTIONS preflight, and the response has to carry Access-Control-Allow-Origin (and, for non-simple requests, Access-Control-Allow-Methods and friends) before the browser will let the actual request fly.
sendBeacon cannot preflight. Its job is to survive unload, pagehide, and visibilitychange, where a blocking OPTIONS round-trip would defeat the purpose. The HTML spec therefore restricts sendBeacon to "simple" requests — but the spec and the implementations have a well-known gap. Chromium-based browsers will send the request with the non-simple content type, then silently discard the response because the CORS check fails. The bytes often reach our nginx, but the response is dropped before JavaScript could ever observe it, and crucially, the boolean has already returned true from the queue. In some configurations and older browser versions, the request is dropped client-side altogether. Either way, the dashboard sees nothing.
Our endpoint was actually well-configured for the eventual JSON payload. The preflight was simply never going to happen, and the request it was supposed to authorise was, from the browser's point of view, unauthorised.
How we caught it
By the time we noticed, the chart had been flat for a fortnight. The first theory was the obvious one: the server was rejecting the payload. We added verbose logging on the handler — body, headers, content-type — and then did the thing we should have done on day one. We stopped trusting the boolean and watched the wire.
The browser dev tools Network tab, with Preserve log on and a hard refresh through a navigation away from the page, showed the POST. It arrived. The response was 200. nginx was happy. The handler logged the event. The server-side count incremented. The dashboard still did not. The disconnect was between the server and the dashboard query, not between the browser and the server — but it forced us to instrument properly, and during that instrumentation we noticed something we had been assuming away: the CORS preflight had never been issued, and the request had only ever been sent because the user agent had decided to fire it anyway. Any cross-origin consumer in a stricter configuration would have seen the request vanish entirely. The fact that ours "worked" was an accident of browser version, not a property of our code.
The fix, and why it works
Two options, both sound. We picked the first because it required no change to the endpoint.
Option A — text/plain payload. Build the Blob with type: 'text/plain;charset=UTF-8' and stringify the JSON yourself before handing it to sendBeacon. text/plain is a simple content type, so no preflight is required, sendBeacon is happy, and the request goes out. On the server, parse the body as JSON exactly as before. The content type is a label, not a contract enforced by your handler.
Option B — fetch with keepalive. If you genuinely need application/json semantics, use fetch(url, { method: 'POST', body: JSON.stringify(payload), headers: {'Content-Type': 'application/json'}, keepalive: true }). keepalive lets the request outlive the page, just like sendBeacon, and unlike sendBeacon it does support a real preflight round-trip. You give up the synchronous boolean; you gain a promise you can actually verify, and a CORS path that matches the rest of your API.
We use Option A for the unload path where we genuinely cannot wait for a promise to settle, and Option B everywhere else.
What we do now
Three rules, written into our runbooks:
- Never trust a sendBeacon return value for correctness. Treat true as a hint that the browser tried, not as evidence that anything arrived.
- Verify on the server, not the client. The ingestion endpoint logs every request with a synthetic id, and the dashboard query counts from that log. The browser is never the source of truth.
- Default to text/plain for beacon payloads, fetch with keepalive when you need real headers. Pre-flight is a luxury sendBeacon cannot afford; do not ask it for one.
The two weeks of missing data were our own fault. sendBeacon did exactly what the spec says it should do. We just had the wrong mental model of what true meant, and the wrong content type on the Blob, and we shipped both without ever looking past the return value.
If you are wrestling with a flaky analytics or telemetry path, or thinking about how unload-safe ingestion fits into a larger product, take a look at what we have built at pharoahtechnology.co.uk/products — SOLAI, GhostWire and the rest of the stack — or reply to this post and tell us what you have seen go wrong. We would rather hear about your war story than repeat ours.