Skip to main content

Shipping a WebGL hero without wrecking your Lighthouse score

three.js is around 300kb gzipped. Here is how we put a real 3D scene on a marketing homepage and still kept the mobile performance score above 95.

4 min read
  • Performance
  • Frontend

Design hands you a hero with a glowing, rotating, translucent object in it. Engineering points out that three.js is roughly 300kb gzipped and that mobile Lighthouse scores are a stated requirement. Both are right.

The resolution is not "use a video instead." It is to stop treating the 3D scene as part of the page and start treating it as an enhancement that some visitors receive.

The core idea: never let 3D touch the critical path

The critical path is everything the browser needs to render and make interactive the content above the fold. A WebGL scene is not in that set — it is decoration, however good it looks. So the rule is simple: nothing about the 3D scene may execute before the page is otherwise done.

In Next.js, that starts with a dynamic import that never renders on the server:

const CubeCanvas = dynamic(() => import("./cube-canvas"), { ssr: false });

That alone is not enough. next/dynamic will still fetch the chunk as soon as the component mounts, which is during hydration — right when the main thread is busiest. You want to gate the mount itself.

Gate on capability, not just on viewport

We check four things before deciding to load anything:

const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wideEnough = window.matchMedia("(min-width: 1024px)").matches;
const capableDevice = (navigator.hardwareConcurrency ?? 4) > 4;
if (reducedMotion || !wideEnough || !capableDevice) return;

hardwareConcurrency is a blunt instrument, but it is the best widely-supported signal available and it correctly excludes most low-end Android devices — which is exactly the population whose Lighthouse score you are worried about.

Then defer the actual decision until the browser is idle:

const schedule = "requestIdleCallback" in window
  ? window.requestIdleCallback
  : (cb: () => void) => setTimeout(cb, 400);
schedule(() => setShouldRender3D(true));

Now the chunk downloads after first paint, after hydration, on a thread that has nothing better to do.

Make the fallback a finished thing

This is the part teams get wrong. If your non-3D path is a grey box or a blurred screenshot, you have built two experiences and one of them is bad — and it is the one most of your mobile traffic will see.

Build the fallback as artwork. We drew ours as an SVG: layered gradients, an isometric lattice, a blur-glow behind it. It costs nothing, it ships in the HTML, and it is genuinely good. It does three jobs:

  1. the poster while the WebGL chunk loads
  2. the permanent artwork on mobile and low-core devices
  3. the permanent artwork under prefers-reduced-motion

Because it is good, the decision to not load three.js for most visitors stops being a compromise.

Cross-fade on real readiness, not on a timer

An easy bug: fade the poster out after a fixed delay, assuming the canvas is up by then. If WebGL fails to initialise — a blocked context, an old driver, a browser flag — you fade to nothing and the hero is empty.

React Three Fiber gives you the actual signal:

<Canvas onCreated={() => onReady()}>

Fade on that. If it never fires, the poster stays, and the visitor sees finished artwork instead of a hole.

Stop rendering when nobody is looking

A requestAnimationFrame loop keeps running when the hero has scrolled away. On a long marketing page that is a lot of wasted GPU and battery for pixels nobody can see.

An IntersectionObserver that flips a boolean, checked at the top of every frame callback, fixes it:

useFrame((state, delta) => {
  if (!group.current || !active) return;
  // …
});

Skip post-processing

Bloom is what makes a scene like this look expensive, and @react-three/postprocessing is the obvious way to get it. It is also an extra dependency and at least one full-screen render pass per frame.

You can get most of the way there for free. Put a large, blurred radial gradient behind the canvas in CSS, and use additive blending on the bright elements inside the scene. On a dark background the eye reads it as bloom, and it costs a single composited layer instead of a render target.

The settings that actually matter

  • dpr={[1, 1.5]} — cap device pixel ratio. Above 1.5 you double fill cost for a difference nobody can see on a decorative object.
  • depthWrite — leave it on for translucent geometry with real gaps between pieces. Turning it off makes every face of every object equally visible, and a lattice of glass blocks turns into wireframe soup.
  • Low ambient light — coloured point lights give you saturation. High ambient washes everything to pastel, and then you compensate by cranking emissive, and now it glows uniformly and reads as flat.

What it buys you

The 3D chunk stays out of the initial bundle entirely. Mobile visitors download and execute none of it. Desktop visitors get it after the page is already interactive, cross-faded in over about half a second — which reads as a deliberate reveal rather than a late load.

The performance requirement and the design requirement were never actually in conflict. They were in conflict with the assumption that everyone has to receive the same page.


We build sites like this for a living. If you have a design that engineering says is impossible, we would like to look at it.

Have a project in mind?

Tell us what you are trying to build. We will tell you honestly whether we are the right people for it.

support@cleverlabs.com.au