September 11, 2026. 8 minute read. Draft, under review.
Keeping a Three.js site at 35 fps on a laptop with integrated graphics.
The measurements, the four start-up stalls that lost the WebGL context, and the rules this site now follows.
Measure on the slowest machine you own
The home page of this site is a React Three Fiber scene: a road, a car, buildings, trees, a photographic sky. The first build ran at 32 frames per second on my Intel Iris Xe laptop and kept rendering while nobody scrolled, holding the GPU at over 90 percent. A performance trace showed a two-second synchronous shader compile right after load, and the renderer counters showed only 55 thousand triangles, so geometry was never the cost.
Everything that follows came from measuring rather than guessing. The site exposes its renderer counters and its start-up timings behind a query flag, and every change was checked against them.
Render on demand, and mean it
The scene renders only when something has changed. A time-based damping loop eases the road position toward the scroll target and requests frames until it settles. An idle loop requests frames at 24 per second while the tab is visible and the visitor has interacted in the last 25 seconds, so the clouds drift and the exhaust puffs, then it sleeps. When the scene has scrolled out of view on the home page, it renders nothing at all.
Two things that looked like optimisations were not. Replacing the two headlight spotlights with additive cone meshes mattered because adding a light at dusk changed the light count and recompiled every material, a 1.2 second stall. Swapping the physically based materials on the kit models for Lambert mattered because the fragment cost on an integrated GPU is per pixel, and most pixels are buildings and ground.
Four ways to lose the WebGL context
While adding a photographic sky and textured surfaces, the WebGL context was lost on about a third of loads. Windows resets a GPU that does not respond for around two seconds, and the browser reports it as a lost context. Per-step timing found four causes, each of which alone was enough.
- The framework requested a frame the moment the loaded models were committed to the scene, and that frame drew everything with every shader still uncompiled: a 1.3 second stall. The canvas now runs with the frame loop switched off until the warm-up has finished.
- Fifty small canvases (signs, boards, a road texture, a ground texture) were painted with Canvas 2D in the same task as that commit. Canvas 2D is GPU rasterised. Painting is now queued and drained one canvas per frame.
- The first draw with each shader program still compiled driver-side variants, so drawing the whole scene in one frame after compilation took 1.2 seconds. The warm-up now reveals the scene one shader program at a time, 17 groups, the longest 150 milliseconds.
- The environment map arrived after the materials had compiled, so every physically based material recompiled at once. The sky is now decoded and prefiltered before compilation, each step in its own frame.
What it measures now
On the same laptop, at a device pixel ratio of 1 and a 1280 by 800 window: 35 frames per second while driving on the development server and 50 on the production build, zero frames while idle, and a longest start-up frame of 150 milliseconds. Playwright runs the suite across Chromium, Firefox and WebKit, on a phone profile and with WebGL disabled, so the plain HTML fallback is tested too.
The rules that fell out of this are short. Nothing renders before every shader is compiled. Heavy GPU set-up gets its own frames. Light count, environment map, tone mapping and shadow type are fixed at creation and never change at runtime. And post-processing is gated on the renderer string, because an ambient-occlusion pass that costs two milliseconds on a discrete GPU cost this laptop 40 percent of its frame rate.