Isotherm

The last beacon is the one that matters

fetch() dies with the page, so Isotherm's final flush uses sendBeacon on visibilitychange → hidden — the only lifecycle event a mobile tab reliably gets.

Someone lands on your orders screen, clicks a filter, nothing happens, and they close the tab. That visitor — the one who clicked, got nothing, and left inside four seconds — is the single most valuable data point a heatmap can have. It is the entire reason the dead-click detector exists. And a naive tracker loses them, because the batch containing that click was still sitting in a queue when the document went away, and the fetch() that would have shipped it was cancelled by the browser the moment navigation started.

The events you most want are the ones the page is least able to deliver.

The queue is not the problem, the exit is

Isotherm buffers. Clicks, gridded mouse dwell, scroll depth — they accumulate in a Transport queue and go out either when the batch fills or when a timer fires:

push(event: AnyEvent): void {
  this.queue.push(event);
  if (this.queue.length >= this.config.maxBatchSize) {
    this.flush();
    return;
  }
  this.schedule();
}

That is the boring, correct design for the steady state. A mousemove-heavy page produces a lot of small events and you do not want one request each. But it means that at any given instant there is un-shipped data in memory, and the question that actually matters is what happens to it when the page dies.

flush() takes one argument, and it is the whole point of the class:

flush(final = false): void {
  // ...
  if (final || !('fetch' in window)) {
    const blob = new Blob([body], { type: 'text/plain;charset=UTF-8' });
    if (navigator.sendBeacon?.(url, blob)) return;
  }

  fetch(url, { method: 'POST', body, keepalive: true, /* ... */ })

Two paths, and note that the beacon path is a preference, not a fork. It returns early only when sendBeacon says yes. If the beacon is refused, or the API is not there, execution falls straight through into the same fetch as every mid-session flush. Hold onto that; it matters later, and I originally got it wrong.

Why visibilitychange, and not the event you'd reach for first

The obvious hook is unload. It is also wrong, and has been for years: registering an unload listener makes a page ineligible for the back/forward cache in Chrome and Safari. You would be trading a real user-facing performance feature — instant back navigation — for your analytics. That is not a trade a tracking script gets to make on someone else's site. Firefox and Safari also fire it inconsistently, and mobile Safari largely does not fire it at all.

The next candidate is pagehide, which is bfcache-aware and does not have that problem. It is genuinely better. It is still not sufficient, because on mobile a tab that gets swiped away in the app switcher, or reclaimed by the OS under memory pressure, frequently never gets a pagehide at all. The process just stops existing. Nothing runs.

The one event a page is reliably given before it can be killed is visibilitychange firing with visibilityState === 'hidden'. Every path out of a page passes through hidden first — backgrounding, switching tabs, locking the phone, closing. It is the last synchronous moment the browser guarantees you.

So that is where the real flush lives:

function flushFinal(): void {
  transport.flush(true);
}

function onVisibility(): void {
  if (document.visibilityState === 'hidden') flushFinal();
}

document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('pagehide', flushFinal);

pagehide is still there, as a backstop for the desktop close-tab path. Both handlers are safe to fire because flush() no-ops on an empty queue — if (this.queue.length === 0) return; — so a double fire on a normal desktop close costs one function call and nothing else.

The cost of choosing hidden: you flush more often than strictly necessary. Someone who alt-tabs to Slack and comes back has triggered a final flush, and their subsequent clicks arrive in a new batch. That is fine. The envelope carries the same sid from sessionStorage and the same layout fingerprint, so the server stitches them back into one session. Isotherm's scroll depth is stored as a per-session maximum precisely so a re-send is idempotent — a re-flush must never look like a second visitor.

text/plain is not laziness

Look at the blob type again: text/plain;charset=UTF-8. The body is JSON. Sending JSON as text/plain looks like a bug.

It is a CORS decision. Isotherm is cross-origin by construction — the snippet runs on app.acme.com and posts to isotherm.app. A cross-origin POST with Content-Type: application/json is not a "simple request", so the browser fires an OPTIONS preflight first and waits for the answer before sending anything. application/x-www-form-urlencoded, multipart/form-data and text/plain are the only three content types on the simple-request allowlist.

A preflight is a full round-trip. An unloading page does not have one to spend. Worse, the preflight is not a request the browser will keep alive for you the way it keeps a beacon alive — you are asking a dying document to complete a handshake. sendBeacon with a Blob sets Content-Type from the blob's type, and there is no header-setting API to override it, which is by design: beacons are constrained to exactly the requests that need no preflight.

The fetch path sets the same header deliberately, for symmetry — one CORS configuration on the server, one code path to reason about, no surprise preflight on the very first flush of a session:

headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
keepalive: true,
credentials: 'omit',
mode: 'cors',

The server parses the body as JSON regardless of what the header claims. This is not a security hole: the Content-Type was never the authorization. The Origin header is, and a page cannot forge that.

The thing a beacon cannot do, and the thing it does not stop

sendBeacon returns a boolean — "the user agent queued this" — and that is all you will ever learn. The response is unreadable by design. You cannot await it, you cannot read its status, you cannot get JSON back.

That constrains a completely different feature. Isotherm's server occasionally replies to a collect POST asking that visitor to serialize the DOM and upload a page snapshot, so the dashboard can draw heat over a frozen copy of the page instead of a live iframe. The election rides in the response:

.then((res) => res.json())
.then((reply) => {
  if (reply?.snapshot === true && this.onSnapshotWanted) {
    this.onSnapshotWanted(this.fp, pageUrl(this.config), reply.mask === true);
  }
})

Here is where I want to correct something I believed for longer than I should have. The comment I left in transport.ts says snapshots "only ever come from a mid-session flush," and I repeated that to people. It is not what the code does. A successful beacon returns early and no response is ever read — true. But a refused beacon falls through to the fetch, and that fetch carries the election handler on the end of it. So the final flush of a leaving visitor absolutely can elect them, on exactly the path the next section describes: a payload over the beacon size cap, where sendBeacon returns false. Tracker.stop() has the same shape; it calls transport.flush(true) and inherits the same fallthrough.

Which means the API is not the guard. The guard is in index.ts, and it is explicit:

const whenVisible = (fn: () => void): void => {
  if (document.visibilityState === 'visible') { fn(); return; }
  const onVisible = (): void => {
    if (document.visibilityState !== 'visible') return;
    document.removeEventListener('visibilitychange', onVisible);
    fn();
  };
  document.addEventListener('visibilitychange', onVisible);
};

If the tab is hidden when the election arrives, nothing is captured. The callback parks on visibilitychange and waits to be looked at again. If it never is, no snapshot happens, the server's per-fingerprint lock lapses, and the next visitor gets asked. That is the correct behaviour, and it needs to be there, because a hidden tab does not service rAF, does not fire IntersectionObservers, and does not decode lazy images. Capture there and you get a half-painted page — the exact artefact the snapshot exists to avoid. requestIdleCallback sits inside whenVisible, not around it, for the same reason: it is throttled into uselessness in a background tab.

The lesson is duller than the one I wanted to draw. I had a story where the transport's limitation and the feature's requirement were the same limitation, enforced for free by the platform. It was a nice story. The code enforces it with a visibility check and a listener, because it has to, and the comment claiming otherwise is now wrong and should be deleted.

Where this is wrong

Two more places, and both are real.

Beacon bodies have a size cap. The spec allows the user agent to reject a beacon over ~64 kB, and Chrome enforces it. sendBeacon returns false and Isotherm falls through to the fetch with keepalive: true — which has its own, shared 64 kB quota across all inflight keepalive requests. On a long session with data-move="1" at a 16 px grid, that final payload can get big. The current code treats a false return as "try fetch and hope"; it does not split the batch. If your maps look cold for long-dwell users, that is the first thing I would check.

A batch dropped is a batch gone. There is no retry, on purpose:

.catch((err) => {
  if (this.config.debug) console.warn('[isotherm] send failed', err);
  // Analytics data is not worth a retry storm against a struggling server. A dropped
  // batch costs a few pixels of heat; a retry loop costs the site.
});

Every client retrying a failed collect against a server that is already falling over is how you turn a blip into an outage — on someone else's site, using someone else's traffic, for data that is statistical anyway. A heatmap is not a ledger. Losing 2% of clicks moves no decision anybody makes. Taking down a customer's admin panel to preserve them would be indefensible.

The rule I would give anyone building this: decide up front whether your payload is evidence or estimate. If it is evidence — billing, audit, consent — none of the above applies and you need durable, acknowledged, retried delivery, and you should not be shipping it from an unloading page in the first place. Isotherm's data is an estimate. So the correct engineering is to make the last flush as likely as the platform allows, and then let it go.

Related