· 12 min read#threejs#performance#nextjs#react

How to Add Three.js to a Portfolio Site Without Killing Performance

My site scored 35 on mobile Lighthouse, with a scroll-driven 3D career road on the home page. Here is how it got to 93 with the 3D still there.

Also on Medium ↗

I ran Lighthouse against my personal site on a phone profile and it came back with a performance score of 35, which is abysmal. This was about a week after I had finished the part of the site I was proudest of, a 3D road you drive down as you scroll, with a building for every company I have worked at (IBM, Walmart, DoorDash, Mysten Labs and Circle), each one lighting up with its logo on the roof as the car pulls up to it.

I built it because I believe most websites are sort of flat and unimaginative, portfolios especially: a name, a photo, a list of jobs with dates next to them. I wanted something that would make a visitor actually stop and look. Instead, the thing I built to grab their attention was making them wait.

We all have heard the advice that 3D on a portfolio site is a trap. A lot of the time we don't think about it while building, because we develop on very fast machines and everything looks great there, but a lot of our users are not, they might be a recruiter opening the link on an average phone on a train, staring at a blank box while three.js downloads. Granted, that advice is mostly right, and nobody is going to wait for a car to load to read about our work history. But I did not want to throw the road away, and as it turned out, I did not have to. A production build of the same page now scores 93 on mobile, with the 3D still there.

Note: not all of that jump came from the 3D. The travel map (mapbox-gl, the single largest chunk on the site at around 440KB gzipped) and the Calendly booking widget were also loading way too early, and fixing them was the same kind of work. The 3D scene was the most interesting one to fix, so it is the one this article is about.

In this article, we will go through the five things that let the 3D scene stay.

1. Don't load three.js until someone could actually see it

The road sits right below the fold. My first version loaded the three.js scene when the section came within one screen-height of being visible, which sounded reasonable until I realized that "within one viewport" is true for almost everyone the moment the page loads. What this means, is that every visitor was paying for roughly 270KB of three.js and a few seconds of main-thread work on a phone, before they had scrolled a single pixel.

The fix was to wait for two things, the section being near the viewport, and the visitor doing anything, a scroll, a tap, a key press:

ts
const START_EVENTS = ["scroll", "pointerdown", "keydown", "touchstart"];
 
function useEngaged() {
  const [engaged, setEngaged] = useState(false);
  useEffect(() => {
    if (engaged) return;
    const start = () => setEngaged(true);
    // A page restored mid-scroll counts as already engaged.
    if (window.scrollY > 0) return start();
    START_EVENTS.forEach((e) =>
      window.addEventListener(e, start, { passive: true, once: true })
    );
    return () =>
      START_EVENTS.forEach((e) => window.removeEventListener(e, start));
  }, [engaged]);
  return engaged;
}
 
// next/dynamic gives three.js its own chunk, fetched on first render.
const CareerRoad3D = dynamic(() => import("./CareerRoad3D"), { ssr: false });
 
// Both have to be true before the scene component (and with it three.js) loads.
const near = useNearViewport(sectionRef); // IntersectionObserver, rootMargin "100% 0px"
const engaged = useEngaged();
if (!near || !engaged) return <ScenePoster />; // the still image from section 2
return <CareerRoad3D />;

I tried requestIdleCallback first, since that is the usual answer for "load this later." It did not work, because the browser considers itself idle during page load more often than we would think, and the chunk kept landing in the middle of the Lighthouse run. Nobody can see the road without scrolling to it, so the first scroll is the signal that actually matters.

Waiting for the visitor is free.

2. Show a picture of the scene while the real one loads

Deferring the scene left a gap, by the time the visitor scrolls down, three.js is only just starting to download. My first answer was to show the 2D version of the road (which exists anyway for devices that can't run 3D, more on that below) and then swap to 3D when it was ready. It worked, but it meant the page switched art styles halfway through the scroll, flat SVG one second and a 3D scene the next.

What I do now is take screenshots of the live 3D scene at its first stop, one for desktop and one for mobile, in light and dark mode, and show the right one until the real scene is ready, then crossfade into it. To the visitor it looks like the scene was always there and just came to life. A small script (pnpm posters) regenerates the four images with a headless browser whenever the scene's look changes, so they never drift out of date.

The poster stays mounted underneath while the canvas fades in on top, and the canvas reports that it is ready from inside its very first rendered frame, not when the JavaScript finishes downloading, and on a phone there can be a noticeable gap between the two:

tsx
const [phase, setPhase] = useState<"loading" | "fading" | "done">("loading");
const onReady = () => setPhase((p) => (p === "loading" ? "fading" : p));
 
return (
  <>
    {phase !== "done" && <ScenePoster />}
    <div className={phase === "loading" ? "opacity-0" : "opacity-100"}>
      <CareerRoad3D onReady={onReady} />
    </div>
  </>
);

onReady comes from a useFrame callback inside the canvas, and once the fade is over a short timer flips the phase to "done", which unmounts the poster.

Note: only one of the four images ever downloads. CSS classes for the breakpoint and the theme hide the other three with display: none, and since all four are lazy-loaded, the browser skips the hidden ones. Next.js's dev warnings will suggest loading the poster eagerly, because it is often the Largest Contentful Paint, but that would fetch all four, so they stay lazy and get fetchPriority="high" instead.

3. Decide up front which devices get the 3D at all

Not every visitor should get the 3D road, and it is much cheaper to decide that before downloading anything than after the scene stutters. Before the scene loads, a single function runs a few checks, cheapest first:

ts
function probe3DSupport() {
  if (matchMedia("(prefers-reduced-motion: reduce)").matches)
    return { ok: false, reason: "reduced-motion" };
 
  const nav = navigator as NavigatorWithHints;
  if ((nav.deviceMemory ?? 8) < 4 || navigator.hardwareConcurrency < 4)
    return { ok: false, reason: "low-end" };
 
  if (nav.connection?.saveData) return { ok: false, reason: "save-data" };
 
  if (!canCreateWebGL()) return { ok: false, reason: "no-webgl" };
 
  return { ok: true };
}

The WebGL check is last on purpose, because it creates a real WebGL context just to see if it can, and there is no point doing that for someone who has already told their OS they don't want animation. Anyone who fails a check gets the 2D road instead, which is plain SVG driven by the scroll position, and still tells the same story. Every fallback also sends an analytics event with its reason, so I can see how many visitors land on 2D and why, instead of guessing.

The 2D road is the floor, so it has to be good on its own.

4. Keep watching the frame rate after it starts

The checks above are a guess. A phone with 8 cores can still be in low-power mode, or hot, or running ten other tabs. So once the scene is running, a small component keeps a rolling average of the last 60 frame times, and acts on it in two steps:

  • If the average stays above 40ms (about 25fps), it lowers the canvas resolution (the device pixel ratio) once, and starts measuring again from scratch
  • If it is still above 50ms (about 20fps) after that, the scene gives up and hands over to the 2D road

Why 40 and not something closer to 33? Because 30fps-capped phones in low-power mode average around 33ms per frame, and those are fine to scroll on, so the first cut-off has to sit above that. The first 30 frames after the scene starts are also thrown out, since that is when shaders compile and every device looks slow.

ts
const STEP_DOWN_MS = 40; // ~25fps
const FAIL_MS = 50; // ~20fps
 
const avg = sum / ROLLING_SAMPLES;
if (!steppedDown) {
  if (avg > STEP_DOWN_MS) {
    onStepDown(); // lower the device pixel ratio
    steppedDown = true;
    resetSamples(); // judge the recovery on a fresh window
  }
} else if (avg > FAIL_MS) {
  onFail("low-fps"); // hand over to the 2D road
}

Lowering the resolution had a gotcha of its own. react-three-fiber's <Canvas> re-applies its dpr prop every time it re-renders, so calling the store's setDpr directly works for a moment and then gets quietly undone. Keeping the DPR in React state and passing it back through the prop is what made it stick:

tsx
const [dpr, setDpr] = useState<[number, number]>(
  isHandheld ? [1, 1.25] : [1, 1.5]
);
 
const handleStepDown = () => {
  setDpr([1, 1]);
  // The scene only renders on demand (section 5), so a dpr change alone
  // won't repaint. invalidate() is r3f's, grabbed from the canvas store.
  invalidate();
};
 
<Canvas dpr={dpr}>{/* ... */}</Canvas>;

Note: a lost WebGL context (the browser can take the GPU away from a tab) is treated the same way as a failed frame rate check. The one exception is the context loss three.js triggers itself when the scene unmounts, which happens every time someone switches to 2D on purpose, and counting that as a failure would have logged every one of those switches as a broken device.

5. Only render a frame when something actually changed

By default, a react-three-fiber canvas renders on every display refresh, forever, even when nothing on screen is moving. For a scene that only changes when the visitor scrolls, that is a lot of battery spent drawing the same frame. Setting frameloop="demand" means the scene only renders when something asks it to, and the only thing that asks is the scroll position changing, or an animation (the car turning around, a building lighting up) that is still easing to its end. When the section scrolls offscreen, rendering stops entirely.

tsx
// visible: an IntersectionObserver flag for the section.
// progress: a small scroll-progress store with a subscribe() method.
<Canvas frameloop={visible ? "demand" : "never"} dpr={dpr}>
  <ProgressInvalidator subscribe={progress.subscribe} />
  {/* road, car, buildings ... */}
</Canvas>;
 
// Asks for exactly one frame every time the scroll progress changes.
function ProgressInvalidator({ subscribe }: { subscribe: Subscribe }) {
  const invalidate = useThree((s) => s.invalidate);
  useEffect(() => subscribe(() => invalidate()), [subscribe, invalidate]);
  return null;
}

There is a catch though. With on-demand rendering, most of the gaps between frames are just the visitor not scrolling, and my first version of the frame rate guard measured the gap between any two frames, so it read "the visitor stopped scrolling for two seconds" as "this device runs at half a frame per second." The fix was to only count a frame time when the previous frame was mid-animation and had already queued the next one, since that is the only case where the gap actually measures how long a frame takes to draw. In react-three-fiber terms, that means checking from an addAfterEffect callback whether the canvas has another frame queued (its internal frames counter is still above zero), and ignoring any gap over a second, which is a backgrounded tab rather than a slow phone.

Idle is not slow.

Put a number on it so it stays fixed

All of this is easy to undo by accident, one new import at the top of the wrong file and three.js is back in the initial bundle, and nobody notices because the site still works fine on a laptop. So the CI pipeline now runs mobile Lighthouse five times against a production build of the home page, the résumé, and the writing page, and fails the build if the median performance score drops below 75, or if accessibility or SEO drop below 100:

json
"assertions": {
  "categories:performance": ["error", { "minScore": 0.75, "aggregationMethod": "median-run" }],
  "categories:accessibility": ["error", { "minScore": 1, "aggregationMethod": "median-run" }],
  "categories:seo": ["error", { "minScore": 1, "aggregationMethod": "median-run" }]
}

The bar sits well below the 93 on purpose, CI machines are slower and noisier than a real phone run, and a check that fails randomly gets ignored. Its job is to catch three.js sneaking back into the first load, which should cost far more points than run-to-run noise does.

Conclusion

To recap, the five things that let the 3D road stay:

  1. Don't load three.js until someone could actually see it, and the first scroll is a better signal than "the browser is idle"
  2. Show a picture of the scene while the real one loads
  3. Decide up front which devices get the 3D, cheapest checks first
  4. Keep watching the frame rate after it starts, and step down before giving up
  5. Only render a frame when something changed

None of this is fancy three.js work, there are no custom shaders in this list. It is mostly about when things happen, and accepting that some visitors are better served by the 2D version.

For what it's worth, the most fun part of the whole project was seeing the vision come together, and the worst part was tuning it. There were a lot of graphical glitches and three.js issues along the way, at one point the asphalt was invisible because the road surface was facing down and three.js wasn't drawing it, and react-three-fiber's default 75° camera turned every building into a fish-eye until I pulled it down to 32°. Every fix in this article came after the road already worked, and I think that is the right order.

The road is on the home page, take it for a drive, on a phone if possible, since that is the whole point.

Get it working first, then tune it afterwards.

← All writing