How to Optimize 3D Web Performance: Three.js, R3F, and the Lazy-Loaded Hero
A 3D hero looks great in a product launch video and great in your Lighthouse audit failure. Here is how we shipped a Three.js homepage that keeps the initial bundle lean and the interactive scene warm.
The 3D homepage of 2run.be is built on Three.js with React Three Fiber. The first version of that page added 600KB to the initial bundle and pushed Lighthouse mobile Performance below 60. The current version ships the 3D scene only after the user has scrolled near it, and weighs a tenth of what the first version did on the critical path. Here is how we got there.
What is in a 3D web scene
A typical R3F scene graph pulls in:
- Three.js itself (~150KB gzipped core, more with controls, loaders, postprocessing).
- React Three Fiber (~30KB) and Drei (~50KB+ depending on helpers used).
- The geometry and material loaders you actually use.
- The shader programs, if you write custom ones.
A conservative baseline is 250-350KB gzipped for a non-trivial scene. A heavier scene with physics, postprocessing, and custom shaders can break 600KB. That number is the entire story for performance: it competes for the critical-path budget with the rest of the page.
Two approaches
Approach A: ship it on the critical path
You commit to the 3D scene being part of the first paint. The entire scene, including geometry loaders and controls, lands in the initial bundle. The user sees the 3D scene as soon as the HTML arrives. The first-paint story is the 3D story.
This works if:
- The rest of the page is genuinely minimal (a portfolio hero, a landing page).
- The 3D scene is the value proposition — selling the scene is selling the product.
- Mobile users in your target market are on Wi-Fi most of the time.
It does not work if:
- The 3D scene is decorative, not functional.
- The page has more content (text, CTAs, a form) than just the scene.
- Mobile LCP is part of your Core Web Vitals commitment.
Approach B: lazy-load after first interaction
You defer the 3D scene to a next/dynamic import with ssr: false, and trigger the load only when the user scrolls near the hero or after a small delay. The initial bundle never carries the 3D payload. The scene appears when the user is about to see it.
This works if:
- The hero content above the fold can stand on its own (an image, a gradient, a typographic statement).
- The first-paint story is the brand promise, not the 3D scene.
- You have an
IntersectionObserveror scroll listener to trigger the load.
This is the approach we landed on for the 2Run homepage. The trade-off:
- Pro: Initial bundle drops by 600KB. Lighthouse mobile Performance jumps from below 60 to above 90.
- Con: The user sees a placeholder for ~0.5s before the scene populates. With good placement of the trigger zone, this feels like the scene "rising up" rather than "popping in."
The implementation sketch
The pattern, stripped to the essentials:
- A
useInViewhook that returns a boolean when the target ref is in the viewport. - A
next/dynamicimport for the heavy scene component withssr: falseand a lightweight loading skeleton. - The hero section renders the typography and CTA eagerly, then conditionally mounts the scene when
inViewflips true.
A subtle gotcha: the scene component should also accept a reduced-motion fallback. Users who have requested reduced motion in the OS get the typography hero without the 3D scene, and Lighthouse Performance is not the only metric that matters.
Mobile and the lower-end tier
The honest truth about mobile 3D: a mid-range Android phone at 30°C battery throttling still cannot match a desktop browser at the same scene complexity. Two paths:
- Detect and degrade. Use a
glcapability check (renderer.getContext() and a small benchmark) and serve a lower-poly scene on weaker devices. Best UX, costs engineering time. - Render unconditionally and accept the cost. Fine for B2B buyers on company-managed devices. Not fine for consumer audiences.
We chose the first path. The detection logic runs once per session and the result is cached in sessionStorage. The fallback scene uses lower-poly geometry and drops the postprocessing pass.
What is not in this story
- Server-side rendering of 3D scenes. There is no SSR for Three.js — the canvas needs a DOM context. SSR stubs for a 3D scene are mostly decorative and add SSR complexity for limited gain.
- GPU shader precompilation. Real wins, but unless you ship a custom postprocessing layer, the browser JIT is good enough.
- 3D on the LCP itself. Targeting 3D as the LCP element is the path to a fast initial paint for a 3D-portfolio and a slow LCP for a content-heavy page. Pick the one you want to be true about.
The take-away
Treat 3D web scenes the way you treat any other heavy feature: profile the bundle, defer what you can, render the right tier on the right device, and own the trade-off explicitly. A 3D scene that fails Core Web Vitals is not a creative win — it is a launch site that does not rank.
