Isotherm

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.

Every time I explain how Isotherm draws a heatmap — the SDK serializes the whole DOM, strips it, inlines the CSS, uploads up to 3 MB of HTML, and the dashboard frames that as the backdrop — the same question comes back within about ten seconds. Why not just take a screenshot? Puppeteer, one PNG, done. It is a good question. It has three answers, and the last one is the one that actually decides it.

The server can't see the page

Isotherm exists for admin panels. Order lists, customer records, the settings screen nobody can find. Every one of those pages is behind a login.

A server-side headless capture means Isotherm's box opens Chrome, navigates to https://app.acme.com/orders?status=paid, and photographs whatever comes back. What comes back is a login form. The pages you most want a heatmap of are exactly the pages a stranger's browser cannot reach, so the screenshot pipeline would produce beautiful, correct, useless captures of the sign-in screen for the entire set of URLs that motivated the product.

The fix is to give the crawler credentials. Think about what that means operationally: Isotherm now stores a working admin session for every customer, refreshes it when it expires, and survives their MFA. A heatmap tool that holds a live admin login for every client it serves is a different risk profile than a heatmap tool, and it is not one I want to underwrite. It also still gets the wrong page. /orders?status=paid renders differently depending on who you are and what tenant you're in — the README calls the query string part of the page identity for exactly this reason — so the crawler's view of the page is nobody's view of the page.

The visitor's browser is already logged in, already on the page, already looking at the thing. It's the only client in the system that can see what needs capturing.

Rasterizing in the browser is worse than serializing

Fine: capture in the browser then. html2canvas, dom-to-image, take a bitmap client-side and upload a PNG.

Two problems. The bundle is one. The whole Isotherm tracker builds to 18 kB minified, 6,915 bytes gzipped. html2canvas is roughly 200 kB minified on its own — a tracking snippet that grows by an order of magnitude for one feature is a snippet people uninstall. The README still says 5.5 kB, which was true two months ago and is a thing I should fix; the build is 6.7 kB gzipped today.

The deeper problem is what those 200 kB are doing. They are a partial reimplementation of a CSS rendering engine in JavaScript, running inside a browser that already has a complete, correct one. It gets box-shadow mostly right, backdrop-filter not at all, and iframes and cross-origin images not ever. You end up debugging why a customer's sticky header renders twice in the heatmap backdrop. Serializing the DOM and letting Chrome render it later costs almost nothing and delegates the hard part to the thing that is good at it.

A PNG has no elements

This is the one that closes the argument.

A click gets recorded with more than one answer to "where". Page-absolute pixels, a horizontal coordinate normalized against the document width, and — the one that matters — a CSS selector plus the fractional offset within that element's box. HeatmapController::points() carries all of it through to the browser, along with the tag and label the "top elements" list is built from:

->get(['x', 'y', 'nx', 'selector', 'ex', 'ey', 'tag', 'label'])
->map(fn ($row) => [
    'x' => (int) $row->x,
    'y' => (int) $row->y,
    'nx' => (float) $row->nx,
    // Carried through so the renderer can re-anchor a point onto its element when
    // the snapshot's layout does not line up pixel-for-pixel with the capture.
    'sel' => $row->selector,
    'ex' => (float) $row->ex,
    'ey' => (float) $row->ey,
    'tag' => $row->tag,
    'label' => $row->label,
])

And the dashboard, holding the snapshot in a same-origin iframe, reads its DOM to place each point:

function resolve(p) {
    if (!frameDoc) return [p.x, p.y];

    let box = anchors.get(p.sel);
    if (box === undefined) {
        let el = null;
        try { el = frameDoc.querySelector(p.sel); } catch (e) { /* selector no longer valid */ }
        box = el ? el.getBoundingClientRect() : null;
        anchors.set(p.sel, box);
    }
    if (!box || box.width === 0) return [p.x, p.y];

    const win = frameDoc.defaultView;
    return [
        box.left + win.scrollX + box.width * p.ex,
        box.top + win.scrollY + box.height * p.ey,
    ];
}

Raw coordinates are the fallback, not the plan. A click belongs to the button, not to the pixel. Between the click and the moment someone opens the map, the button moves — a longer product name wraps a row, a banner appears above the fold, the sidebar collapses at a slightly different width. Pixels rot. Selectors mostly don't.

You cannot do this over an image. querySelector on a PNG returns nothing, so every point is stuck at the coordinate it was recorded at, and the heat drifts a few pixels off the control it describes. Nobody notices, because it still looks right. That failure mode — plausible and wrong — is the one this whole codebase is written against.

The same DOM readability is what powers the "top elements" list, which is the part of the dashboard people actually act on: topElements() groups by selector, so you get "Export CSV — 1,204 clicks" instead of a warm blob at (880, 410).

It also constrains how the snapshot can be locked down. The obvious hardening — a fully sandboxed iframe — would make frameDoc null and take the anchoring with it, so the frame keeps exactly one capability:

<iframe id="frame" sandbox="allow-same-origin" referrerpolicy="no-referrer">

Sandboxed, but same-origin, so the DOM stays readable. Everything else is bought back at the response: the snapshot endpoint serves the document under default-src 'none'; script-src 'none'; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'. Two layers, and the controller's own docblock says which is which — "a sandboxed iframe on the dashboard side is the second layer; this is the first." Inert, but legible, because reading it is the point.

What we do rasterize

The screenshot idea is right in exactly one place, and the code takes it there. A <canvas> holds pixels, not markup; clone it and you get an empty box, so rasterizeCanvases() replaces each one with an <img> of its own pixels — JPEG at quality 0.82, PNG only if it somehow came out smaller — capped at MAX_CANVAS_BYTES = 400 * 1024.

Two different things go wrong there, and they have two different outcomes. If the image is over budget, the clone is swapped for a grey rgba(128,128,128,.06) div of the same dimensions: the geometry survives, which is what the heat needs. If the canvas has been tainted by cross-origin pixels, toDataURL() throws and the code simply gives up on it:

} catch {
  return; // tainted by cross-origin pixels; nothing we can legally read
}

No placeholder, no swap — the cloned <canvas> element is left in the document exactly as it was, empty. It still occupies its box, so the layout below it doesn't shift, but nobody drew a grey rectangle there on purpose. A page whose entire UI is a WebGL canvas gets an image and nothing else, and for that page the screenshot people were right all along.

The bill actually comes due somewhere else

Rejecting screenshots doesn't make the problem go away, it moves it. A serialized DOM captures the page in whatever state it happens to be in, and a modern page does not finish rendering when it loads. It finishes rendering when you scroll it. Reveal libraries start elements at opacity: 0 and un-hide them from an IntersectionObserver. Images below the fold are loading="lazy" and have no pixels. Snapshot without scrolling and you capture a tall empty void with one band of content where the viewport happened to be — and the heatmap gets drawn over the void.

So settle() runs on the live document before the clone, scrolling the whole page through in steps of max(200, innerHeight * 0.9), bounded at MAX_STEPS = 60, waiting one painted frame each time so the observers fire:

function frame(): Promise<void> {
  return new Promise((resolve) => {
    let done = false;
    const finish = (): void => { if (done) return; done = true; resolve(); };
    requestAnimationFrame(finish);
    setTimeout(finish, 120);
  });
}

That setTimeout race is not belt-and-braces, it's load-bearing, and I learned it the hard way. requestAnimationFrame does not fire in a background tab. A bare await rAF hangs there forever — and since the capture holds the server's single-visitor election lock, one person with the page open in a background tab blocks every other visitor from ever being asked. It deadlocks silently, produces nothing, throws nothing, and the dashboard says "no snapshot yet" until the heat death of the universe.

Then lazy images get up to 1500 ms to arrive, and the clone gets a stylesheet that pins whatever the scroll pass failed to trigger — a carousel on a timer, an element revealed by a click:

*, *::before, *::after {
  animation: none !important;
  transition: none !important;
  animation-play-state: paused !important;
}
[data-aos], .aos-init, .aos-animate, .reveal, .fade-in, .fade-up, [class*="animate-"] {
  opacity: 1 !important;
  transform: none !important;
  visibility: visible !important;
}

The first rule is global; the second is not, and that asymmetry is the whole design. Forcing opacity: 1 on everything would unfold every dropdown, tooltip and closed modal into the backdrop, so the reveal is aimed at the selector conventions the reveal libraries actually use. That list is a guess about other people's class names, which is the least defensible thing in this file. A site with a bespoke reveal class gets a hole in its snapshot and I will not know until someone tells me.

That's the trade. Screenshots move the work to a server that cannot log in and hand you an image you cannot query. Serialization keeps the work in the browser that already has the page and hands you a document you can read — and charges you one flicker, on one visitor, once per layout version, plus the ongoing tax of teaching a page to hold still long enough to be photographed. The second bill is the one worth paying.

Related