Performance React Accessibility

Why my three.js portfolio was slow on mobile, and what a 3D site needs

My old three.js portfolio scored 34 on mobile Lighthouse. What made it slow, and a checklist for a 3D site that stays fast on phones.

By Mateusz Muszarski Updated 6 min read Po polsku

My old three.js portfolio was slow on mobile because it made a phone download 13.8 MB and boot a whole 3D app before it showed any text. Mobile Lighthouse scored it 34, with the largest contentful paint (LCP) at 46.9 s. The fix isn't less 3D. It's text first, 3D later and on a budget, and controls that work without a mouse wheel.

TL;DR

  • What went wrong: most of the first download was an emoji font and a 3D model, the HTML had no text, and only a mouse wheel could move the journey.
  • What a 3D site needs on phones: text in the HTML first, the 3D loaded on demand, a byte budget, fewer pixels and frames, reduced motion honoured, zoom never locked.
  • What I do now: no WebGL in the hero. The old flight is a lazy video, and the shaders on this site wait for the reader's first move.

What was the old site?

A portfolio you fly through: 12 camera stops from Earth to Neptune, driven by scrolling, with one section on each planet. I built it alone in 2025 with three.js 0.177, React Three Fiber 9, drei, postprocessing, GSAP and Vite 6. It has 13 GLSL shader programs written by hand, on the approach from Bruno Simon's Three.js Journey course.

The old site
The old portfolio in motion, recorded on a desktop. On a phone it never got this far. Planet textures: Solar System Scope, CC BY 4.0.

It was my favourite thing to show. Then, on 5 October 2026, I measured it.

How slow was it, in numbers?

Lighthouse 13.5.0, simulated throttling Desktop Mobile
Performance score 60 34
First contentful paint 0.8 s 42.5 s
Largest contentful paint 1.2 s 46.9 s
Total blocking time 1,240 ms 930 ms

The mobile run emulates a mid-range phone on a 1.6 Mbps connection with a 4× slower CPU; each profile ran once. The worst part doesn't show up in a score:

  • Phones got an “Oops!” screen. Any window 900 px wide or less was told the device was incompatible, while the same 13.8 MB downloaded behind it.
  • Touch did nothing. The journey listened only to the wheel event, so a phone could never leave the first stop.
  • There was no keyboard path at all.
  • About 85 seconds from the start button to the contact form, for a script scrolling as fast as the camera allowed.

Why was it slow?

Nothing in the HTML to paint

The raw HTML had no indexable text. Every section lived in a drei <Html> overlay inside the 3D scene, so it existed only once the app ran. On web.dev, the list of elements that can be the LCP (images, video, CSS background images, block-level text) has no <canvas>. So the first paint waited for the whole app to boot.

Bytes nobody needed first

Two files made up most of the first download, before anyone pressed start: a colour emoji font (7.6 MB compressed) and an asteroid model (4.5 MB). Neither is needed to show a name and a role.

A whole 3D app on the main thread

The first load booted the whole 3D stack: three.js, React Three Fiber, drei, postprocessing and GSAP. Downloading it wasn't the expensive part; running it was. Under the 4× CPU slowdown, Lighthouse counted 40.5 s of JavaScript boot-up and 43.1 s of main-thread work.

A gate, then a slow tour

A start button, a door animation, then camera moves of 1-8 s each, with input ignored while each ran. A fine showreel, and a poor first meeting for someone who opened the link on a phone.

What does a 3D site need to stay fast on phones?

This is the checklist I'd hand my 2025 self. The genre hasn't solved it: in my Lighthouse study of 16 developer portfolios, the 3D ones had a median score of 35.5.

1. Text first

Render every real fact (name, role, projects, contact) as HTML on the server. Make the LCP element text or a poster image, never a loader label. The 3D is a layer over the content, not its container.

2. Load the 3D on demand, or not at all

Load the 3D chunk with a dynamic import() (MDN: dynamic imports "are only evaluated when needed"), on the first interaction or when the canvas nears the viewport. Idle time is a weaker trigger. requestIdleCallback isn't Baseline (Safari ships it disabled), should get a timeout for work that must run, and isn't free, as my own shader showed (below).

Under reduced motion or without WebGL, skip the download: show the poster and stop there.

3. Set a budget before the first commit

Budget Target
LCP, lab mobile 2.5 s or less
Total blocking time 200 ms or less
JavaScript before the LCP (gzip) 150 KB or less
3D chunk, lazy (gzip) 200 KB or less with plain three.js
3D assets, lazy, on phones 1 MB or less

For scale, from a bundle test on 5 October 2026 (esbuild, minified, gzip): a tree-shaken three.js scene is about 131 KB. With React and React Three Fiber it's about 310 KB, and a few drei helpers make it about 400 KB, before any model or texture. So keep R3F off the critical path.

4. Draw fewer pixels, fewer times

The three.js manual on high-density screens: top phones have 9 device pixels for each pixel of a standard display, so "they have to do 9x the rendering". React Three Fiber's default dpr is [1, 2]; on phones, cap it lower. At 1.5, the GPU fills a quarter of the pixels it would at 3.

Then draw only when something changes. The manual again: "rendering continuously is a waste of the devices power and if the user is on portable device it wastes the user's battery." In R3F that's one prop:

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

const coarse = matchMedia("(pointer: coarse)").matches;

export function Scene() {
  return (
    <Canvas dpr={coarse ? [1, 1.5] : [1, 2]} frameloop="demand">
      {/* call invalidate() whenever something changes the picture */}
    </Canvas>
  );
}

If the scene must animate, stop the loop offscreen. Browsers pause requestAnimationFrame in background tabs and hidden iframes (MDN), not for a canvas scrolled out of view. That's an IntersectionObserver's job.

5. Honour reduced motion

MDN describes prefers-reduced-motion as a setting "to minimize the amount of non-essential motion". A camera flight between planets is exactly that. Give those visitors a still frame or the poster. And give everyone a pause button for anything that moves on its own for more than five seconds (WCAG 2.2.2 (opens in a new tab)). How this site does both is in the post on reduced motion and a pause button.

6. Never lock zoom; support touch and keys

Keep user-scalable=no and a low maximum-scale out of the viewport tag; Lighthouse has an accessibility audit for exactly that. If the canvas needs drag gestures, set touch-action on the canvas, not the page. MDN warns that touch-action: none can still block zoom over that area.

Every way through the scene needs a touch path and a keyboard path: swipes, arrow keys, buttons or plain links. Mine had a wheel listener and nothing else.

What do I do now?

This site has no WebGL in the hero. The H1 is my name in real text, final in the first paint. The old flight is now the video above, on the home page: a poster and preload="none", with sources attached after the reader's first interaction. It plays only while half visible; under reduced motion the poster stays.

Four small shaders run on other pages now, written without a library. The simplest is the goo behind the My life title:

Goo
The goo behind the My life title, recorded on this site: blobs of the brand colours that follow the pointer and swell on a tap.

My first version followed my own checklist and started the goo when the page went idle. Lighthouse disagreed: compiling the shader during load cost 150-350 ms of blocking time, and My life fell to 83-91 on mobile. Now the goo waits for the reader:

// Text first: the goo starts at the reader's first interaction
// or a pointer move over it. Not on an idle timer.
afterInteraction(() => void go());
host.addEventListener("pointermove", () => void go(), { once: true, passive: true });

The whole order, which the other three shaders follow too:

  1. 01 Text paints The title, with the same colours in CSS behind it
  2. 02 The reader moves A tap, key, scroll or pointer move
  3. 03 The runner loads One triangle and one fragment shader, no library
  4. 04 It draws while seen Pixel ratio capped at 1.5, stopped offscreen
  5. 05 It holds still One frame when paused or for reduced motion

The home page that replaced the flight scores 95-96 on mobile Lighthouse (local production build, median of 3 runs, 7 October 2026). How the rest was built is in the case study of this site.

Limits

  • The old site's numbers are one lab run per profile, from headless Chrome without a GPU. Nothing here ran on a physical phone.
  • The checklist comes from research and from this site's small shaders, not from rebuilding a full 3D world like the old one.
  • Under reduced motion, the goo still downloads its runner to draw one still frame. My checklist says skip it; that's the next fix.

The space portfolio is still live, with its code public: both links are on my work page.

Sources

  1. Largest Contentful Paint (LCP), web.dev (opens in a new tab) (accessed )
  2. Responsive Design (three.js manual) (opens in a new tab) (accessed )
  3. Rendering on Demand (three.js manual) (opens in a new tab) (accessed )
  4. Scaling performance (React Three Fiber docs) (opens in a new tab) (accessed )
  5. Canvas (React Three Fiber docs) (opens in a new tab) (accessed )
  6. import() (MDN) (opens in a new tab) (accessed )
  7. Window: requestIdleCallback() method (MDN) (opens in a new tab) (accessed )
  8. requestIdleCallback (Can I use) (opens in a new tab) (accessed )
  9. Window: requestAnimationFrame() method (MDN) (opens in a new tab) (accessed )
  10. prefers-reduced-motion (MDN) (opens in a new tab) (accessed )
  11. Understanding Success Criterion 2.2.2: Pause, Stop, Hide (W3C) (opens in a new tab) (accessed )
  12. touch-action (MDN) (opens in a new tab) (accessed )
  13. Lighthouse accessibility scoring (Chrome for Developers) (opens in a new tab) (accessed )
  14. Solar System Scope, planet textures (CC BY 4.0), used in the old space portfolio (opens in a new tab) (accessed )

All posts