How not to DDoS yourself with your own analytics
Drawing a heatmap needs a copy of the page, but asking every visitor for one turns a customer's traffic spike into an attack on your own server — so the server elects exactly one.
To draw a heatmap you need something to draw it on. Not the live page in an iframe — that fails three ways, all of them silent — but a frozen copy of the DOM as it looked when the clicks happened. Isotherm captures that copy from a real visitor's browser: serialize the document, inline every stylesheet it is allowed to read, rasterize the canvases, ship the result.
That capture is the single most expensive thing the SDK ever does. The client-side cap is 3 MB, and on a dense admin panel a real snapshot lands in the high hundreds of kilobytes.
Now put a thousand people on that page at once. The naive design — every visitor uploads a snapshot, the server keeps the first and discards the rest — has a thousand browsers each freezing the DOM and each pushing most of a megabyte at you. You have written a distributed load generator and installed it on your customer's site. It fires hardest exactly when their traffic spikes, which is exactly when you least want to be handling a gigabyte of redundant HTML. The waste is on their bandwidth too: their users pay for the upload, on their connections, for data you throw away at the far end.
So the permission to send a snapshot is not a flag. It is a lock.
One line does the work
private function electSnapshotter(PageVersion $version): bool
{
if ($version->hasSnapshot()) {
return false;
}
return Cache::add("snapshot:pending:{$version->id}", true, now()->addMinutes(2));
}
Cache::add is atomic: it writes only if the key is absent, and returns whether it wrote. First request to ask for a version that has no snapshot gets true. Every other request in the next two minutes gets false. That is the whole election.
The result rides out on the ingest response, which is the batch endpoint every visitor is already hitting:
return [
'ok' => true,
'tracked' => true,
'snapshot' => $result['wants_snapshot'],
'mask' => $result['mask_text'],
];
snapshot: true reaches exactly one browser. The other 999 get snapshot: false on a response they were going to receive anyway, at zero marginal cost.
The unit of election is the page_version, not the page. A version is a layout — the fingerprint of the DOM skeleton with repeated siblings collapsed and content stripped. That is the right granularity, because the snapshot is what the heatmap is drawn on, and a redesign that moves the button invalidates the backdrop as thoroughly as it invalidates the clicks. New fingerprint, new row, no snapshot, election reopens. Nobody has to remember to bump a version number, which is the point of fingerprinting in the first place.
Note also where the site-level kill switch bites:
'wants_snapshot' => $site->snapshotMode !== 'off' && $this->electSnapshotter($version),
Short-circuit, on the request side. It would have been easier to check the setting in SnapshotController and drop the upload. That check is worthless: the visitor's browser has already serialized the DOM and paid to send it. A snapshot setting that only takes effect after the upload arrives is not a setting, it is a delete button.
The two-minute TTL, and why the lock is allowed to be wrong
The lock expires after two minutes. It is not released on success and it is not renewed.
That is deliberate, and it means the system tolerates both of its failure modes:
- The elected visitor never delivers. They close the tab, the capture throws, the tab stays in the background forever (the SDK refuses to capture a hidden tab —
requestAnimationFrameisn't serviced,IntersectionObservers don't fire, lazy images don't decode, so you'd snapshot an unsettled skeleton of the page you were trying to preserve). Two minutes later the key vanishes, the next flush to arrive is elected instead, and the version eventually gets its picture. - Two visitors are elected. A slow uploader gets asked, the lock expires while their
requestIdleCallbackis still pending, someone else is asked, and both eventually POST. This is fine, andSnapshotControllersays so out loud:
// Not an error worth shouting about: two visitors can be elected in a race, and the
// slower one arrives to find the work already done. Their upload is simply discarded.
if (! $version || $version->hasSnapshot()) {
return $this->respond(['ok' => true, 'stored' => false], 202, $origin);
}
The whole design target is "not a thousand", not "provably one". Two is a rounding error; a thousand is an outage. Building a lock that could actually guarantee exactly-once — renewal heartbeats, explicit release, a handoff protocol when the elected visitor bails — buys you nothing here and adds a state machine that can wedge. The two-minute lapse is a timeout that also happens to be the retry policy.
The road not taken: capture it ourselves
The obvious alternative to conscripting a visitor is to fetch the page server-side. Headless Chrome, our IP, our CPU, zero bytes of anyone else's bandwidth. No election, no lock, no snapshot: true protocol at all.
It doesn't work, for a reason that is specific to the product. Isotherm is for admin panels. The pages worth heatmapping are behind a login, and often behind an org switcher, a role, and a feature flag. A headless browser has no session. The best it can do is screenshot the login screen — the one page in the app nobody needs a heatmap of. And every layout variant that depends on who is looking (an admin sees the extra column; the trial account sees the upsell banner) is invisible to it. Those variants are precisely the ones the fingerprint splits into separate versions, so they are precisely the ones that need their own backdrop.
The visitor's browser is already authenticated, already rendered, already in the right state. It is the only thing in the system that can see the page. The cost of using it is that you must not use all of them at once.
The subtlety: you cannot elect on the last flush
Here is the bit that took me a second pass to get right.
The SDK has two delivery paths. Mid-session batches go over fetch(). The final flush, on visibilitychange → hidden, goes over sendBeacon — because a normal fetch is cancelled when the document unloads, and the visitor who clicks once and leaves is exactly the visitor a heatmap exists to explain.
sendBeacon returns a boolean meaning "queued", not a response. The server's reply is unreadable by design. So the election result can only be acted on in the fetch path:
fetch(url, { method: 'POST', body, keepalive: true, credentials: 'omit', mode: 'cors' })
.then((res) => res.json())
.then((reply) => {
if (reply?.snapshot === true && this.onSnapshotWanted) {
this.onSnapshotWanted(this.fp, pageUrl(this.config), reply.mask === true);
}
})
I originally reasoned about this backwards — treat the last flush as the natural moment to ask, since the visitor is done and we're no longer competing for their CPU. That is exactly the flush whose response nobody will ever read. The server can happily elect a beacon, mark the version pending for two minutes, and get nothing, because the browser it elected has already thrown away the answer.
The fix is not to make beacons readable. It's to accept that snapshots only ever come from a visitor who is still on the page, which is fine: anyone who stayed long enough to trigger one fetch flush stayed long enough to serialize a DOM. If the only traffic a version ever gets is single-beacon bounces, it never gets a snapshot — and a layout that nobody engages with for even one flush interval is a layout with no heat to draw anyway.
The same reply carries mask, the site owner's text-masking choice, for the same reason: masking has to happen in the browser, before the DOM leaves the visitor's machine. Sending it back per-response means switching a client to masked mode takes effect on the next snapshot, without them redeploying the snippet.
Where this is the wrong design
Three cases, and I'd rather name them than pretend they don't exist.
The lock is per-process-visible cache, not per-database-row. Cache::add is atomic within one cache store. Run two app servers against separate local caches — an APCu or file driver per box — and you get one election per box. Use a shared Redis or a shared memcached and it holds. If you deploy Isotherm across nodes with a local cache driver, this whole post is a lie about your system.
Very low traffic makes the two-minute TTL feel long. A staging admin panel with one visitor an hour: they get elected, they leave without a mid-session flush, and the version sits snapshotless until the next person shows up. That's correct but it looks broken. It is why a failed capture reports itself (snapshot.reportFailure) and writes snapshot_error — so the dashboard can say why there is no backdrop instead of showing "not yet" forever while every visitor fails identically on the same CSP-blocked webfont.
It assumes a snapshot is worth having once. For a page whose layout is genuinely fluid — a dashboard whose widget grid depends on user config — the fingerprint forks constantly, each fork elects a visitor, and you get a lot of elections. The fix there isn't in the lock, it's in the fingerprint (collapse harder) or in the dashboard's merge tool. The election is only ever as sensible as the versioning it sits on top of.
The tracker is 5.5 kB gzipped. None of it does anything clever. The cleverness — such as it is — is one Cache::add on the server, answering "no" nine hundred and ninety-nine times.
-
Why not just take a screenshot?
A headless screenshot can't log into the admin panel it's supposed to photograph, and a flat image has no elements to re-anchor clicks onto. The DOM snapshot is the cheaper answer.
-
The page is not finished when it loads. It is finished when you scroll it.
Snapshots of a marketing page came back as a tall dark void, because reveal libraries hold elements at opacity:0 until an IntersectionObserver fires — and a snapshot has no scripts to fire it.
-
A dwell map is not a mouse trail
Sampling the pointer on a 100 ms timer and counting grid cells turns a minute of mousemove from ~7,000 events into a few hundred integers — and produces a better signal than the trail would have.