Stop the scenes hanging the tab

Fixkamo-marketing
Shipped
September 6, 2026 at 2:20 PM UTC
Author
Kamo
Commit
ab890f8

Reported: "after about a minute or two the page looks to animate away then animate back and its completely locked up. Browser wants to close the tab." Reproduced in the code rather than the browser; three separate full-page hazards, one of which is the cause and two of which were waiting their turn. 1. useShelves was forcing layout on content-visibility subtrees — THE CAUSE It called getBoundingClientRect() on every matching element in the document, on every scroll frame. Twenty-three elements behind a rAF throttle reads as nothing, which is why it survived review, and on almost any other site it would be nothing. globals.css puts `content-visibility: auto` on every `main section:not(#home)` — deliberately, because eight below-the-fold sections being laid out while the LCP image waited was measured at 1,810 ms of render delay. getBoundingClientRect is a layout-forcing read, so asking it about an element inside a skipped subtree makes the browser lay that subtree out immediately to answer, and then skip it again once it turns out to still be off screen. Sixty times a second, for all eight sections, it does not merely undo the optimisation — it converts it into a recurring cost that never existed before. The visible half follows from `contain-intrinsic-size: auto 900px`: those sections alternate between their remembered height and the placeholder, so the document height oscillates, and every `animation-timeline: view()` scroll reveal on the page re-runs off the changed offsets. That is the page animating away and back. The compounding layout cost is the lockup. It now never asks a question of an element that is not already rendered. An IntersectionObserver — which forces nothing — keeps the set of candidates near the viewport, and only that set is ever measured. Elements in a skipped subtree are never in it. The candidate list is rebuilt every 3s instead of every frame. 2. will-change: transform came off the overlay layers It bought nothing that was not already paid for: a canvas repainting every frame gets its own compositing layer regardless. What it did buy was a full-viewport texture for the WASH layer — a static CSS gradient that never animates — held for the life of the scene, several megabytes at 1440p, on a page already carrying ninety-odd backdrop-filter panels. 3. mix-blend-mode came off all five scenes that used it A blend mode on a fixed full-viewport layer makes the compositor read the whole page back every frame to blend against it. Gating it to `quality === "full"` was the right instinct at the wrong depth; on the page where a full-page effect had just hung a tab, shipping another one I cannot measure from here is not a trade worth making on a guess. Nothing much is lost — `screen` with white over a dark canvas is close to what plain alpha draws — and the scenes that need additive light already do it inside the canvas with `lighter`, against the other particles rather than against the document. AND A WATCHDOG, because the next one will not be found by review either useSceneCanvas now measures the real frame interval — not the `dt` handed to scenes, which is clamped and so can never report trouble — and if the page cannot hold 15fps across ten unbroken seconds the scene stops for good, clears itself off the screen and says why in the console. Ten seconds is long enough that a GC or another tab stealing the CPU cannot trip it, short enough that a reader gets their page back before reaching for the close button. The flag latches, so returning to the tab does not restart a scene into the conditions that starved it, and a stood-down scene stops rebuilding its sprites on resize. Eighteen canvas scenes running against live DOM geometry on hardware nobody here has seen will fail again in some way none of us predicted. This decides what that costs: an undecorated page, not an unusable one. Also fixed: `dt` was documented "never 0" while only the ceiling was clamped. Two rAF callbacks can share a timestamp, and a scene dividing by dt to recover a rate gets an Infinity into a position. Floored at 1/480s. No ordinary visitor was affected — today resolves to no holiday, so this was only reachable through the footer picker. tsc clean, 14 guards green, 187 routes still prerendered.

All changes

Like what you see shipping?

Every one of these updates lands in your workspace automatically. Start free and watch it grow week after week.

Start Free ForeverView Pricing