Panning 600 DOM Nodes at 60fps Without a Canvas
Six hundred real elements panned and zoomed without a canvas. The trick is a hard split: React owns what exists, the RAF loop owns where it is.
The arthis.land homepage is a city of bricks you can drag around and zoom into. A brick is 90px per size unit, so a "2x5" is 450px wide, and at any given moment around six hundred of them are sitting in the document as real elements with real images, real headings and real links. Every instinct trained on twenty years of web performance advice says: draw that on a canvas. We did not. The wall holds 60fps on a mid-range Android, and the reason is not that DOM is secretly fast. It is that we never touch layout during a gesture.
Why not a canvas
A canvas is one element. That is the whole appeal and the whole problem. Everything inside it is pixels the browser has no opinion about, which means four things we rely on stop existing the moment we switch.
Accessibility. A brick is a link to a room. In the DOM it is an anchor: it is in the tab order, it announces itself, it has a focus ring, a context menu, an "open in new tab". On a canvas none of that is there and you rebuild it by hand as a parallel hidden DOM tree that mirrors the drawing. You now maintain two representations of the same city and they will drift.
Images. Each brick carries a thumbnail. The browser's image pipeline decodes off the main thread, honours loading="lazy", picks from srcset, and shares one cache entry across every brick pointing at the same file. On a canvas you own decode scheduling, and a decode that lands on the main thread mid-pan is a dropped frame you have to schedule your way out of.
Hit-testing. Working out which brick is under the pointer is free: the browser already knows, because it has to. On a canvas you write a spatial index and keep it correct through every zoom, every data update, every reflow. That index is the kind of code that is right for eight months and then silently wrong after someone adds a brick size.
Text. Labels stay selectable and findable. They rasterise at the settled zoom level, so a room name at 1.8x is text, not an upscaled bitmap.
The price for keeping all four is a rule we do not get to break: during a pan or a zoom, nothing may cause layout, and nothing may cause a React render of the wall. The rest of this is how that rule is enforced.
The split: React owns what exists, the RAF loop owns where it is
There are two authorities over the same tree and they are deliberately kept apart.
React decides what exists: which bricks are mounted, what image each one points at, what its label says, whether it is a room or an empty plot. That data arrives procedurally at boot, so the wall paints before any network work happens at all, and is later enriched from the build-time snapshot. If you want to know why the first frame owes nothing to a server, that is covered in first paint before the network.
A requestAnimationFrame loop decides where it is: the current pan offset and scale. It does not ask React. It writes to the DOM directly through refs.
// viewRef is a ref, not state. Mutating it renders nothing.
function applyTransforms() {
const v = viewRef.current;
wallRef.current.style.transform =
'translate3d(' + v.x + 'px,' + v.y + 'px,0) scale(' + v.scale + ')';
// Same frame, same technique: one element, one property, no render.
compassRef.current.style.transform = 'rotate(' + headingOf(v) + 'deg)';
}
Two property writes per frame, both on the compositor's favourite property, on two nodes. React is not involved and does not know anything moved.
Undo this and nothing throws. Put x, y and scale in useState and every pointermove schedules a render of the entire wall: several hundred brick components reconciled, every memo re-evaluated, at input rate. The frame budget at 60fps is 16.7ms and a full pass over the tree eats most of it before the compositor gets a turn. The symptom is not a crash. It is that the wall lags a few pixels behind your finger and rubber-bands when you stop, which is exactly the class of regression that survives code review.
The compass needle is the same story compressed into one element. It is a single arrow that rotates as you move around the city. Driving it through state means sixty full app re-renders a second to change one rotate(). Through ref.style.transform it costs a string concatenation.
The ~11Hz commit gate
React cannot be left out forever. It needs the viewport to decide which bricks to mount, the URL hash tracks position, and a few chrome elements read the current scale. So the RAF loop does commit to state, just not every frame.
const COMMIT_MS = 90; // ~11 commits/sec, not 60
function frame(now) {
applyTransforms(); // every frame, always
if (now - lastCommitRef.current > COMMIT_MS) {
lastCommitRef.current = now;
setView({ ...viewRef.current }); // React catches up here
}
rafRef.current = requestAnimationFrame(frame);
}
Motion is smooth because applyTransforms() runs unconditionally. Correctness is maintained because React sees a fresh viewport roughly eleven times a second. The gap between those two rates is the whole performance story: the expensive work happens at 11Hz and the cheap work happens at 60Hz.
Delete lastCommitRef and the app still works perfectly. It just feels heavy, in the specific way where users say "it's a bit laggy" and cannot point at anything. There is no error, no failing build, no red guard. This is why the throttle has a comment on it explaining what breaks, and why it looks like dead code to anyone tidying up.
Culling with a visible memo
Six hundred is already the culled number. The city is bigger than what is mounted. A memo keyed on the committed view filters bricks to those intersecting a padded viewport.
const visible = useMemo(
() => bricks.filter(b => intersects(b, pad(viewportOf(view)))),
[bricks, view]
);
The padding is not cosmetic, and it is sized against the commit gate. Because the visible set can be up to 90ms stale, a brick entering from the edge during a fast fling would pop in late without a margin wide enough to cover a fling's worth of travel in that window. The two numbers are coupled: change the commit interval and the padding is wrong.
Without the memo, every offscreen brick stays live. Style recalculation walks them, images decode for things nobody can see, and the compositor carries layers off the edge of the world. On desktop you might get away with it. On a phone the extra memory shows up as the browser evicting decoded images you are about to need.
The honest cost: find-in-page only finds bricks that are currently mounted. Ctrl-F is not a reliable way to locate a room in the city. We accept that because the site has a search of its own, but it is a real thing we gave up, not a rounding error.
One rect per gesture
A good mouse produces around 200 pointer events a second. getBoundingClientRect() in that handler forces the browser to flush pending style and layout synchronously, 200 times a second, on a tree of several hundred nodes we are simultaneously transforming. That is the single most expensive mistake available here, and it is a one-line mistake.
function onPointerDown(e) {
// Touch goes through its own path; mixing them makes the wall
// drift toward the last tap. See the touch notes.
if (e.pointerType !== 'mouse') return;
rectRef.current = wallRef.current.getBoundingClientRect(); // once
...
}
Measure at pointerdown, keep it in a ref, reuse it for the life of the gesture. This is safe precisely because of the choices above: transform does not affect layout, so the element's box genuinely does not move while we drag it. A resize or a scroll invalidates the cached rect; a pan does not.
That pointerType === 'mouse' filter is the other line people delete. It is not defensive clutter. Removing it lets touch events into a code path calibrated for mouse deltas, and the wall creeps toward wherever you last tapped. The full reasoning is in touch handling on phones.
The canvases that are on the page anyway
Some sub-pages render hex grids on real canvases, and they taught us two things worth keeping.
First, one shared scroll listener for all of them rather than one per canvas. Scroll fires at input rate; N listeners means N independent handlers each doing their own arithmetic on the same event, and the cost scales with how many canvases happen to be on screen rather than with how much work there is to do. One listener, one pass, dispatch to whoever needs it.
Second, redraws are suspended while the page is scrolling, via a hexPageIsScrolling() check. Repainting a canvas during a scroll puts your drawing code in direct competition with the compositor for the same main thread at the exact moment it is busiest. Without it the worst symptom is frame drops on phones, which is the same sentence as everything else here: it does not break, it degrades.
Why none of this costs anything to ship
The critical path budget is 100 kB gzipped, and React takes 45 kB of that before we write a line. The app bundle is about 32 kB gz, which puts the wall at roughly 81 kB gz including the HTML — under budget, with room. (The database client is another 55 kB gz, but it is a dynamic import that first paint never waits for and that a visitor served by the snapshot never fetches at all, so it is not on this path.) There is no animation library, no virtualised-list dependency, no state manager. Every technique above is a handful of lines: a ref, a timestamp comparison, a filter, a cached rectangle. The budget is enforced by a build guard, so a dependency that would fix one of these "properly" has to justify itself against a number.
What is still unsolved
Zoom out far enough and culling stops helping, because everything is genuinely visible. We cap the minimum scale rather than solve it; a proper answer means swapping bricks for a cheaper representation past a threshold, which reintroduces the two-representations problem we avoided by not using a canvas.
During an active pinch, text is a scaled bitmap and looks soft until the gesture settles and it re-rasterises. That is visible and we have not fixed it.
The 90ms commit interval is a value that felt right on the devices we had, not a number derived from measurement. On a slower phone a fling covers more ground between commits than the padding assumes, and a brick can arrive late at the edge.
Most importantly: there is no test suite, and the build guard checks structure and size budgets, not frame times. Nothing in CI will tell you that you turned the commit gate into a no-op or moved a transform into state. Those regressions are found by dragging the wall and noticing it feels wrong, which is a terrible detector and currently the only one we have.
If you remember one thing, remember the split. React owns what exists. The RAF loop owns where it is. Every performance bug on this page has been something crossing that line.
Written from the arthis.land and arthis.space codebases by Aayush Kumar, who builds both. If something here is wrong, or you have shipped the same problem a better way, tell me — corrections get made and credited.
Arthis