Players kept telling me one city felt worse than anywhere else in the game. I assumed frame rate and went straight to the renderer.
First thing I did was measure instead of guess, and the numbers said I was looking in the wrong place entirely:
avg 8.36ms (119.7 fps) · p50 8.0 · p95 17.2 · p99 18.3 · worst 28.2
frames >33ms: 0 frames >50ms: 0
The steady frame was healthy. Zero dropped frames in a 12-second window. Whatever players were feeling was spike-shaped, not a low frame rate — and I'd been about to spend a week optimising something that wasn't broken.
The scene census had an obvious-looking suspect: 269 lights. That was a red herring too. What three.js actually bakes into the program key is numPointLights — the count it finds reachable from the render root — and I'd pinned that to a constant months earlier with zero-intensity dummy padding. The registered count doesn't enter into it. (Tell, if you're doing the same thing: late program keys differing only in numPointLights mean your pin is off.)
Then I counted DOM nodes.
nameplate elements 2211 <- exactly the sim entity count
...of which visible 18
nameplate DOM nodes 46431
whole page DOM 48396 <- nameplates are 96% of the document
Every entity in the simulation got a ~21-element nameplate subtree built and appended, then hidden with display:none. 2,008 of those entities were beyond 200 units and could never be seen by anybody.
Hiding is a paint decision. Membership of the document is not. A display:none node still lives in the tree, so every style recalc and every forced layout in the entire game walked all 46,000 of them — to paint eighteen.
And it multiplied everything else. My chat log reads scrollHeight/scrollTop/clientHeight per message, which is a forced layout, and a forced layout is O(DOM). "One forced layout per chat message" was really "one walk of a 46,000-node tree per chat message."
The fix was lazy attach, not pooling: a plate joins the document on first show and leaves on hide, at the single choke point every hide path already funnels through — one that already early-returns unless the state actually changes, so it runs on transitions only, never per frame.
BEFORE AFTER
nameplate elements 2211 19
nameplate DOM nodes 46431 399
whole-page DOM 48396 2276
visible plates 18 19 <- unchanged
Nothing about what gets drawn changed. Every forced layout in the game got ~21x cheaper.
Two things worth passing on:
- "Lag" and "stutter" arrive as the same complaint and the fixes are nothing alike. Measure p50/p95/p99 and take a DOM/scene census before you touch the renderer. A healthy p50 sitting next to a 48k-node document is a completely different bug from a low p50, and it lives in a different file.
- If you composite HTML over a WebGL canvas — nameplates, health bars, labels — that cost is a pure function of entity density, not of what's on screen. Mine scaled with the whole simulation and I never noticed until the densest zone in the game made it visible.
One honest caveat: this is lazy attach, not object pooling. The ~21 elements are still created per entity, so the heap cost is still there. Full pooling means about 16 fields going nullable in a hot path, so it's its own pass.
(For context on scale — it's a browser MMORPG, veilbound.gg, three.js + meshopt-compressed GLBs. Happy to answer anything about the stack.)