Skip to content
All articles
Performance

WebGL that doesn't tank your Lighthouse score

Shipping three.js and GSAP on a marketing site: server-rendered content, lazy mounting after LCP, dpr clamping, and a fallback state designed on purpose.

Daniel OkoroFrontend lead
7 min read

A three.js hero looks incredible in the pitch deck and can take a marketing site from a 96 to a 41. The failure is predictable: a renderer in the critical path, a canvas that paints before the copy does, a device pixel ratio of 3 on a mid-range Android drawing nine times the fragments it can afford. None of that is inherent to WebGL. It comes from mounting the canvas as though it were content.

The content is not the canvas

Start from a rule that settles most of the arguments: every word, link and call to action on the page must exist in the server-rendered HTML with the canvas removed entirely. The 3D layer is decoration on top of a page that already works.

That buys you three things. The LCP element becomes a heading or a hero image, both of which the browser can paint within a few hundred milliseconds. Crawlers and link previews see a complete page. And the no-WebGL fallback stops being a special case, because it is simply the page without its ornament.

Concretely: never render the headline as text inside the canvas, never gate navigation behind a scroll animation that needs the renderer, and never let the canvas establish page height. If it sits position: fixed behind normal document flow, most failure modes disappear before you write a line of shader code.

Mount after LCP, not on hydration

Importing three.js at the top of a client component puts it in the entry chunk, parsed and compiled before the page becomes interactive. Even code-split, mounting inside a bare useEffect fires during hydration and competes with everything else for the main thread.

We wait for two conditions: the largest paint has happened, and the canvas is actually near the viewport.

tsx
"use client";

import { useEffect, useRef, useState, type ComponentType } from "react";

const prefersReducedMotion = () =>
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const hasWebGL = () => {
  try {
    const canvas = document.createElement("canvas");
    return Boolean(canvas.getContext("webgl2") ?? canvas.getContext("webgl"));
  } catch {
    return false;
  }
};

export function SceneMount() {
  const holder = useRef<HTMLDivElement>(null);
  const [Scene, setScene] = useState<ComponentType | null>(null);

  useEffect(() => {
    if (prefersReducedMotion() || !hasWebGL()) return;
    if (navigator.hardwareConcurrency <= 4) return; // low-end device, stay static

    let cancelled = false;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;
        observer.disconnect();
        // yield until the main thread is idle, then pull in the renderer
        requestIdleCallback(
          () => {
            void import("./scene").then((mod) => {
              if (!cancelled) setScene(() => mod.Scene);
            });
          },
          { timeout: 2_000 },
        );
      },
      { rootMargin: "200px" },
    );

    if (holder.current) observer.observe(holder.current);
    return () => {
      cancelled = true;
      observer.disconnect();
    };
  }, []);

  return (
    <div ref={holder} aria-hidden className="absolute inset-0 -z-10">
      {Scene ? <Scene /> : <StaticPoster />}
    </div>
  );
}

StaticPoster is a compressed still frame of the scene, 25 to 60KB in AVIF. It renders immediately, it is what reduced-motion and no-WebGL visitors keep permanently, and it removes the flash of empty space while the module downloads. Render it as a real img with explicit dimensions so it contributes nothing to CLS.

Watch the LCP consequence though. If the poster is large enough to become the LCP element, you have moved the problem rather than solved it. Keep it behind the text, dimmed, so a heading remains the LCP candidate.

dpr clamping and frameloop

Two settings account for most of the runtime cost.

Device pixel ratio. Rendering at dpr 3 means nine times the fragments of dpr 1. On a phone that is the difference between a steady 60fps and a warm chassis at 24. Clamp to [1, 1.5] on mobile and [1, 2] on desktop. Nobody picks the difference on a soft-focus background in a blind comparison; everybody notices the frame rate.

Frame loop. A scene rendering continuously burns battery and holds the main thread even when nothing is moving. If the animation is scroll- or pointer-driven, set frameloop="demand" and invalidate only when something actually changed.

tsx
"use client";

import { Canvas, useThree } from "@react-three/fiber";
import { useEffect } from "react";

function PauseWhenHidden() {
  const { invalidate } = useThree();

  useEffect(() => {
    const onVisibility = () => {
      if (document.visibilityState === "visible") invalidate();
    };
    document.addEventListener("visibilitychange", onVisibility);
    return () => document.removeEventListener("visibilitychange", onVisibility);
  }, [invalidate]);

  return null;
}

export function Scene() {
  return (
    <Canvas
      frameloop="demand"
      dpr={[1, 1.5]}
      gl={{ antialias: false, powerPreference: "low-power", alpha: true }}
      camera={{ fov: 40, position: [0, 0, 6] }}
    >
      <PauseWhenHidden />
      <Particles count={4_000} />
    </Canvas>
  );
}

antialias: false plus a cheap SMAA pass is usually faster than MSAA and visually close enough. powerPreference: "low-power" keeps laptops on the integrated GPU, which for a decorative background is the right trade. Always stop rendering when document.hidden is true; a scene animating in a background tab is pure waste and it turns up as battery complaints rather than as a metric.

Dispose properly on unmount as well. Geometries, materials and textures are not reclaimed on their own, and a route change leaking a few megabytes each time will crash a tab during a long session.

Budgets, set before anyone models anything

Give the design or 3D partner numbers up front. Retrofitting a budget onto delivered assets means someone redoes work for free, and it will not be the person who set the budget late.

  • Renderer payload: three.js core plus your scene under 180KB gzipped, loaded lazily, never in the entry chunk. Import from three/examples/jsm selectively; a wildcard import drags in loaders you will never call.
  • Geometry: under 150k triangles on screen for a marketing hero, with repeated objects instanced rather than duplicated. Under 60 draw calls; merge static geometry and share materials.
  • Textures: KTX2 with Basis compression, not PNG. A 2048px PNG at 5MB becomes roughly 350KB as KTX2 and uploads to the GPU without a decode stall on the main thread. Cap total texture memory near 40MB.
  • Models: glTF, Draco-compressed.
  • Shaders: watch compile time. A heavy shader can compile for 200ms on a mid-range phone, synchronously, and that lands squarely in your INP.

Measure on a real mid-range Android, not a MacBook with a 4x CPU throttle. The throttle is a rough proxy for the main thread and tells you nothing about the GPU.

GSAP without layout thrash

Scroll-driven animation is where Core Web Vitals quietly die. The rules are short:

  • Animate transform and opacity only. Animating top, height or margin forces layout every frame.
  • Never let a ScrollTrigger pin change document height after first paint. Reserve the space in CSS.
  • Do not interleave your own getBoundingClientRect() calls inside onUpdate. GSAP batches reads and writes internally and a stray measurement defeats it.
  • Kill triggers on route change. Orphaned ScrollTriggers accumulate across client-side navigations and every one of them runs on every scroll event.

For simple entrance animations, use CSS or the Web Animations API and skip GSAP entirely. It earns its 40 to 60KB when there is a real scrubbed timeline. For a fade-in it does not.

The fallback is a design deliverable

Reduced motion is not an edge case. Somewhere around 5 to 10% of desktop users have it enabled, and on a corporate fleet with animation disabled by policy it is far higher. Treat the static state as a designed state with its own artboard, not as whatever happens when JavaScript fails.

In practice the static version needs real composition: the poster image, the same typography and spacing, gradients or an SVG standing in for the depth the scene provided. We build that state first and demo it to the client on its own, which reliably prevents a design that only makes sense in motion. Respect the preference at runtime too, not only at mount, since people toggle it: listen for change on the media query and tear the scene down when it flips.

How we work

We agree the poster frame, the reduced-motion state and the asset budgets during design, before anything is modelled, and the numbers go in the same document as the acceptance criteria. Every 3D build ships with Lighthouse and field measurements taken on the same page with and without the canvas, so the cost of the effect is a number the client can look at rather than a feeling. The rule we hold to: if the scene cannot be an enhancement, it does not go on the marketing site.

Daniel Okoro · Frontend lead

Part of the NorthStackHub delivery team. Writes here when a client build turns up a decision worth documenting — usually after the second time we have had to explain it on a call.

Meet the team

Facing the same problem?

We scope this kind of work every week. Describe what you are building and we will send back an approach, a timeline and a number — no charge for the thinking.

Replies within 4 business hours · No obligation · You keep the scope document