All articles

Performance

Shipping a 3D hero in 435 KB

A scroll-driven 3D scene that renders on a cheap Android and downloads on a bad connection, without looking cheap. What actually moved the numbers, and the WebGL trap that cost me an afternoon.

Jithin Roy, Full Stack Developer

By

Full Stack Developer, Kerala, India

8 min read

Most 3D on the web is quietly enormous. Drop a model from Sketchfab or a client's product render straight into a page and you are shipping two to ten megabytes before anything moves. On a desktop with a fibre connection nobody notices. On a mid-range Android phone on 4G in a car park, the hero never arrives and the visitor never sees the thing you built the page around.

I ran into this building a podcast studio page: a microphone that slides in from the right while scroll drives the camera through the space to land on an empty chair, mic pointed at it, waiting for a guest. The studio sells premium recording time, so a blocky low-poly hero would have undercut the entire pitch. The microphone started at 2.31 MB and shipped at 435 KB. The chair came in at 329 KB. Neither costs the browser anything extra to decode.

Here is what actually moved those numbers, and the part that had nothing to do with file size at all.

Compress the geometry with meshopt, not Draco

Draco is the compression everyone reaches for first, and its ratios are genuinely good. But Draco needs a decoder, and that decoder is a separate WebAssembly fetch of roughly 200 KB that has to land, instantiate, and run before your model can be drawn. If you are optimising a hero specifically because you care about how fast it appears, paying 200 KB and a Wasm instantiation to save a bit more geometry is often a net loss.

Meshopt compresses slightly less aggressively but decodes far faster, and critically, drei's useGLTF already enables the bundled Meshopt decoder by default. There is no extra request and no setup. The compression happens entirely offline in gltfpack:

# -cc = compress geometry with meshopt
# -tc = convert textures to KTX2/Basis (see the next section for why I skipped this)
npx gltfpack -i mic.glb -o mic.opt.glb -cc
gltfpack ships with meshoptimizer and does the geometry pass in one command.

Use Draco when the model is the whole payload and a couple of hundred kilobytes of decoder amortises across several megabytes of geometry. Use meshopt when the model is one element on a page that has other work to do. A hero is the second case almost every time.

Textures are usually the actual problem

Geometry compression gets the attention, but on most models the textures are the bulk. A single 4K PBR set (base colour, normal, roughness, metalness) will dwarf the mesh no matter what you do to the vertices. Two questions matter far more than the codec you pick.

First: how large does the texture actually need to be? A microphone that occupies a third of the viewport at its largest does not need 4K maps. Dropping to 1K was invisible at every size the model is ever drawn, and it cut the payload by more than the geometry compression did.

Second: which format? The textbook answer is KTX2 with Basis Universal, because it stays compressed in GPU memory rather than being decoded to raw RGBA. That is the right answer for a scene with many models or a memory-constrained target. For two small models I used WebP instead, for one specific reason: three's GLTFLoader handles the EXT_texture_webp extension natively, so there is no transcoder to fetch and no configuration to get wrong. KTX2 needs the Basis transcoder wired up, which is another asset and another failure mode.

The rule I ended up with: pick the format whose decoder is already in the bundle you are shipping. A slightly larger file that needs nothing extra usually beats a smaller one that drags a transcoder behind it.

Combined, meshopt geometry plus 1K WebP textures took the microphone from 2.31 MB to 435 KB. Roughly an 81% reduction, with no visible quality change at any size the model actually renders.

The trap: probing for WebGL leaks a context

This is the part that cost me an afternoon, and it has nothing to do with bytes.

You should not mount a WebGL canvas on a device that cannot run it. So you probe first: create a canvas, ask for a context, branch on whether you got one. The obvious implementation is also a bug:

// Looks fine. Is not fine.
const probe = document.createElement("canvas").getContext("webgl2")
setWebgl(!!probe)
The probe context is never released, and nothing about this code suggests it needs to be.

Browsers cap how many live WebGL contexts a single page may hold. On desktop that ceiling is high enough that you will never notice. On some mobile GPUs it is as low as eight. Dropping the reference to the probe does not free the context, because garbage collection does not deterministically release GPU resources, so on a page with a few canvases the probe can be exactly what starves the real one. The symptom is maddening: the canvas throws on mount, only on mobile, only sometimes, and the code that broke it looks completely unrelated.

The fix is to hand the context back explicitly:

const probe = document.createElement("canvas").getContext("webgl2")
setWebgl(!!probe)
// Give the context straight back; mobile GPUs cap how many a page may hold
// and a leaked probe can starve the real <Canvas> below.
probe?.getExtension("WEBGL_lose_context")?.loseContext()

One line, and it is the difference between a hero that works on every phone and one that intermittently fails on the cheap ones you do not own.

Resolve capability checks in effects, not in render

Both of the decisions above (does this device do WebGL2, has this visitor asked for reduced motion) are client-only facts. If you read them during render on a server-rendered page, the HTML the server produced and the first client render disagree, and React tells you so.

Framer Motion's useReducedMotion is the easy one to get wrong: it returns the matchMedia result on the very first client render, which the server could not have known. Resolve both in an effect and let the first paint be the conservative branch:

const [reduced, setReduced] = useState(false)
const [webgl, setWebgl] = useState<boolean | null>(null)

useEffect(() => {
  const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
  const update = () => setReduced(mq.matches)
  update()
  mq.addEventListener("change", update)
  return () => mq.removeEventListener("change", update)
}, [])

if (reduced || webgl === false) return <StaticHero />

Stop rendering when nobody is looking

A hero occupies the top of a page that keeps going for several more sections. Once the visitor scrolls past it there is no reason to keep a render loop running against the GPU, draining a battery to draw something nobody can see.

An IntersectionObserver handles it, with one detail worth getting right: attach it through a callback ref rather than a plain ref. If React ever swaps the underlying node, a plain ref leaves you observing a detached element, which reports not-intersecting forever and pins your hero off permanently.

const ioRef = useRef<IntersectionObserver | null>(null)

const setTrackRef = useCallback((el: HTMLDivElement | null) => {
  trackRef.current = el
  ioRef.current?.disconnect()
  ioRef.current = null
  if (el) {
    const io = new IntersectionObserver(
      ([entry]) => setInView(entry.isIntersecting),
      { rootMargin: "100px" },
    )
    io.observe(el)
    ioRef.current = io
  }
}, [])
The observer follows the node instead of pointing at whatever was there first.

Two fallbacks, because capability checks are not enough

A WebGL2 probe tells you the browser can create a context. It does not tell you the GPU is not blacklisted, that the driver will not crash, or that a model will not fail to parse. So there are two layers underneath the 3D branch: a static hero for anyone the probe rules out or who prefers reduced motion, and an error boundary around the canvas that renders the same static hero if anything throws at runtime.

The static version is a real design, not a blank rectangle. If a third of your visitors might see it, it is not a fallback, it is a second layout.

The CDN detail that will bite you

The models load straight from a CDN, and there is a preload in the page so the fetch starts before React gets to the loader. That preload only helps if the URL matches the loader's request byte for byte, including any cache-busting query, so both should be generated from one constant rather than typed in two places.

The sharper edge is what a query string does and does not do. On a Cloudflare zone that ignores query strings when building its cache key, a version query busts the browser cache but never the edge. Which means:

  • Replacing a 3D asset requires a new filename. Bumping a version query changes nothing at the edge.
  • If you point the code at a filename before uploading the file, the edge caches the 404 and holds it for a day or more.
  • That filename is then burnt. The version query cannot clear it, because the zone never looked at the query in the first place. You either purge by hand or move to a new name.

I have two burnt filenames in that project's history as evidence. Upload the file first, then point the code at it.

What actually mattered

  1. Texture resolution was the single largest win. Ask how big the model is ever drawn before reaching for a codec.
  2. Meshopt over Draco, because the decoder is already in the bundle and a hero cannot afford a 200 KB prerequisite.
  3. WebP over KTX2 for a small scene, for the same reason: nothing extra to fetch or configure.
  4. Release the probe context. One line, and it is the difference between working and intermittently broken on cheap phones.
  5. Resolve capability checks in effects so the server HTML and first client render agree.
  6. Stop the render loop off-screen, and attach the observer through a callback ref.
  7. Two fallbacks, and treat the static one as a real design.
  8. Upload assets before referencing them, and change filenames rather than query strings.

None of this is exotic. It is mostly asking what a file is actually for, and refusing to ship a prerequisite you have not budgeted for. The full build, including the scroll-driven camera and the rest of the platform it sits in, is written up in the Synerggy Events case study.