Isotherm

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.

The snapshot came back at 1440x8200 pixels and almost all of it was nothing. A header, one band of visible content roughly where the visitor's viewport had been, and then several thousand pixels of empty page with the heat floating over it. The DOM was all there. Every section, every heading, every image tag. It just did not render.

Isotherm draws heatmaps over a frozen copy of the page rather than a live iframe, for reasons I'll defend elsewhere: X-Frame-Options blanks an iframe silently, and a live page is today's page, so clicks recorded before a redesign get drawn over the layout that replaced them, which is worse than showing nothing because it looks right. So we serialize the DOM once per layout fingerprint and store it. Every <script> goes on the way out, and the captured document gets a <meta http-equiv="Content-Security-Policy" content="script-src 'none'; object-src 'none'; frame-src 'none'"> stamped into its head as a second lock on the same door. That's non-negotiable — it's a customer's DOM being replayed on our origin.

And that constraint is exactly what broke the marketing page.

Nothing reveals if nothing runs

Half the pages on the internet ship AOS, GSAP ScrollTrigger, or framer-motion. All three work the same way: the element starts at opacity: 0 (usually with a transform: translateY(24px) for good measure), an IntersectionObserver watches it, and when it crosses into the viewport the library adds a class or sets an inline style and it fades in.

That reveal is state, and the state lives in the script.

Clone the DOM of a page like that after a normal pageview and you capture it mid-thought. The section that happened to be on screen has aos-animate on it and is visible. Everything below the fold is still sitting at opacity: 0, waiting for an observer callback that is never going to arrive, because we stripped the thing that would have delivered it:

const STRIP = 'script,noscript,iframe,object,embed';

Lazy images have the same shape of problem with a different cause. loading="lazy" means the browser deliberately has not fetched the bytes. There is no decoded image, and in the srcset case there isn't even a decision yet about which candidate the image would have been. The snapshot inherits an <img> that is, functionally, a promise nobody is going to keep.

I spent an embarrassing amount of time on the wrong hypothesis. I assumed the CSS hadn't inlined properly, because "styles missing" and "everything invisible" look the same from a screenshot. They are not the same. The CSS was inlined perfectly. It was inlining opacity: 0, faithfully, exactly as the page had asked.

The page has to do it, because only the page can

The fix is not clever. It's that we don't get to skip the work — we just have to do it while the scripts are still alive. Before cloning, scroll the live document all the way through, so every observer fires and every lazy image starts loading, then put the scroll position back.

async function settle(): Promise<void> {
  const doc = document.documentElement;
  const original = window.scrollY;
  const height = Math.max(doc.scrollHeight, doc.offsetHeight);
  const step = Math.max(200, window.innerHeight * 0.9);

  const MAX_STEPS = 60;
  let steps = 0;

  for (let y = 0; y < height && steps < MAX_STEPS; y += step, steps++) {
    window.scrollTo({ top: y, behavior: 'instant' as ScrollBehavior });
    await frame();
  }

  window.scrollTo({ top: original, behavior: 'instant' as ScrollBehavior });
  // …then wait, briefly, for lazy images to actually arrive
}

One painted frame per step is enough: IntersectionObserver callbacks are dispatched from the same rendering steps as requestAnimationFrame, so if a frame painted with the element in view, the observer has fired. The step is nine tenths of a viewport, which leaves consecutive stops overlapping by ten percent. There is no comment in the source explaining why, and I'm not going to reverse-engineer a justification for a number I probably picked by feel. It's a cheap margin.

The visitor sees a flicker. That is the entire cost. It happens to one visitor per layout version, because the server hands out the "please snapshot this" permission as a lock, not a flag — otherwise a thousand concurrent users each serialize the DOM and upload a megabyte, which is a self-inflicted DDoS triggered by your customer's traffic spike.

The bound, and why it isn't optional

MAX_STEPS = 60 and a 1500 ms cap on waiting for images. Neither is a nice-to-have. Infinite-scroll pages exist; a page that grows as you scroll it will happily keep the loop going until the visitor closes the tab. And the frame wait is worse than it looks:

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);
  });
}

requestAnimationFrame does not fire in a hidden or throttled tab. A bare await rAF hangs there forever. And since the electing visitor holds the server's snapshot lock, that one background tab would block every other visitor from being asked, while producing nothing, reporting nothing, and throwing nothing. A page opened in a background tab is completely ordinary. That bug would have been permanent and invisible. So the frame is raced against a timer: if frames aren't coming, stop waiting for them.

What settling can't reach, and the blunt instrument for it

Scrolling triggers observers. It does not trigger a carousel that advances on a timer, or a panel that reveals on click. For those, the snapshot gets a stylesheet appended:

*, *::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;
}

Note what this deliberately does not do. The road I rejected was * { opacity: 1 !important }. It's tempting — it fixes the void in one line, no settle pass, no scrolling, no flicker. It's also wrong, and wrong in the direction that produces a plausible-looking lie. Every dropdown, every tooltip, every closed modal on the page is a transparent element. Force them all visible and the backdrop unfolds into a collage of UI that no user ever saw simultaneously, with the heat draped over it. The heatmap would look fine. It would be fiction.

So the selector list is scoped to things that are animation conventions, and yes, that's a heuristic that will miss a custom reveal class. Missing one is fine. The failure is a section that stays faded, which is visible and obviously wrong. Over-revealing is the failure that looks correct.

Pixels are not markup

A <canvas> holds pixels. Clone it and you get an empty box — any page whose hero is a chart or a WebGL scene loses it entirely. So each canvas is read from the live document and replaced in the clone with an <img> of its own bitmap, hard-capped at MAX_CANVAS_BYTES = 400 * 1024 against a total snapshot budget of 3 MB. A full-bleed retina canvas is easily several megabytes as a PNG data URL — one hero animation can exceed the entire limit on its own, and then you capture nothing. A backdrop missing its charts beats no backdrop. Over the cap, the canvas becomes a grey box of exactly its getBoundingClientRect() dimensions, because deleting it would collapse the layout and drag every click below it out of alignment with the heat.

The encoding is where I'd stop and read carefully, because the code does not do what its own comment says:

const jpeg = source.toDataURL('image/jpeg', 0.82);
const png = jpeg.length > MAX_CANVAS_BYTES ? '' : source.toDataURL('image/png');
data = png && png.length < jpeg.length ? png : jpeg;

The comment above it claims PNG is a fallback for flat-colour charts, which compress better lossless. The line underneath makes that fallback unreachable in precisely the case it was written for. If the JPEG already blows the cap, the PNG is never generated at all — png is '', data is the oversized JPEG, and the canvas becomes a grey box. A big flat-colour dashboard chart is the most likely thing to produce a fat JPEG and the most likely thing PNG would have rescued. The lossless retry only ever runs when we didn't need it. That is a real bug and it is mine; the guard was meant to avoid encoding a second multi-megabyte data URL for nothing, and it ended up skipping the encode that mattered.

There is a second one, and it is worse because it is silent. The comment in capture says canvases are rasterized before the strip pass because the passes match live elements to cloned ones by index, so both trees must have the same shape. They don't:

clone.replaceWith(img);   // rasterizeCanvases — inserts a NEW <img> into the clone

eagerImages then runs and pairs Array.from(document.images) against doc.querySelectorAll('img') by index. The live list has no entry for a canvas. The clone now has an <img> where each canvas was. On any page with a canvas above an image, every subsequent pairing is off by one and currentSrc is written onto the wrong element. The ordering that was supposed to protect the invariant is what breaks it. Matching by index across two trees you are actively mutating was never sound; it needs a stamped id on the live node, or the rasterize pass has to run after the pairing.

For images that are paired correctly, the settle pass loaded them, but the clone still needs to be told what was loaded:

if (source?.currentSrc) {
  clone.setAttribute('src', source.currentSrc);
  clone.removeAttribute('srcset');
  return;
}

currentSrc is the URL the browser actually settled on after resolving srcset and sizes. It's the only honest answer to "what is this image, right now, at this width" — and in a snapshot there is no layout negotiation to redo it. loading is dropped from every clone, and images the browser never reached at all fall back to the usual lazy-loader conventions (data-src, data-lazy-src, data-original).

Where this is wrong

Settling mutates the live page of a real person who is using it. A jump to the bottom and back is not free: if that page has a scroll-position-restoring router, or an infinite feed that fires an XHR per viewport, you have just triggered up to 60 of them on someone's session. We accept that because it's one visitor per layout and the flicker is under a second. On an app with heavy scroll-linked side effects, it's the wrong trade, and the honest answer there is a headless capture on the server rather than in the visitor's browser. We don't do that, because a server can't log in to your admin panel.

The lesson generalizes past heatmaps. Any tool that serializes a live DOM — visual regression, archiving, e2e fixtures — inherits this. A modern page is not a document that has been rendered. It is a machine that renders in response to being used. If you photograph it without using it first, you get a photograph of a machine at rest, and you will spend an afternoon debugging your CSS pipeline before you work out that nothing was ever wrong with it.

Related