The loading budget for 3D: split the chunk, load on visibility, know when to stop

A 3D page pays three costs — download, first-frame compilation, per-frame drawing. Each needs its own budget, and any one of them can turn good-looking into janky.

A 3D page pays three separate costs. They have to be budgeted separately.

Cost one: the download

The island’s scene chunk is 582.9 KB (about 150 KB gzipped). Only people who open the isle page should pay it, and only at the moment they actually need it. Three gates do that:

  1. Split by page. The page HTML carries a 1.2 KB bootstrap script; the scene arrives through a dynamic import(), is not written into the HTML and is not preloaded.
  2. Load on visibility. An IntersectionObserver watches the stage with a 200px margin. If the reader never scrolls to it, not one byte is downloaded.
  3. Load when idle. Once visible, wait for a requestIdleCallback (Safari has none, so fall back to a 200 ms delay) and stay out of the way of the first screen’s CSS, fonts and scripts.

Together the three gates mean three things: no other page on the site carries three.js, the isle page’s first screen does not compete with it, and leaving before you reach the stage costs nothing.

Cost two: first-frame compilation

Shader compilation happens on the first render. Ignore it and the reader sees skeleton, hitch, scene. So compile before mounting:

renderer.compile(scene, camera);

Meanwhile the loading state belongs to an HTML skeleton rather than to script-generated markup: the skeleton is in the document and its height is fixed by clamp(), so the page has its final height before the script arrives and nothing below it gets pushed around.

Cost three: every frame

Item Value How it is held down
Draw calls around 380 instancing (grass, flowers, moss: one call each)
Triangles around 50k low poly plus flat shading, colour from vertices not textures
Shadows computed once shadowMap.autoUpdate = false
Pixel ratio capped at 1.75 no chasing 3× on high-DPI screens
Post-processing one bloom pass no multi-pass chain

Also: with prefers-reduced-motion the animation stops but the interaction does not. Auto-orbit stops, petals stop, the water’s time stops advancing — but dragging, zooming and switching camera presets all still work. That is not a performance optimisation, it is respecting a setting.

Failure and degradation

If WebGL cannot start, show one sentence and a retry button instead of a blank rectangle. On webglcontextlost, stop the animation loop. Neither is difficult, and together they decide whether the page is broken or merely limited in a bad environment.

Knowing when to stop

If the 3D is decoration — an illustration inside an article, say — do not put it above the fold and do not add a dependency to the whole site for it. It is destination pages that earn it: the reader came for this thing and will wait 150 KB.

Conversely, if a page needs three screens of scrolling before the 3D appears, and the 3D is not the reason the page exists, the right move is not to optimise it but to not build it.

Closing

3D performance problems rarely live in the renderer. They live in never having budgeted the three costs separately: no gate on the download, no thought for the first frame, per-frame tuned by feel. Write the three budgets down and everything left is local.

← Back to all posts

Comments

…