Optimizing 3D in React: From Janky to Buttery
Building this portfolio taught me things about browser GPU pipelines I genuinely did not want to learn. The hard way.
Multiple simultaneous WebGL contexts. Unpooled geometries. Uncompressed 4K textures. At one point, I had three separate Three.js Canvas instances running on a single page, each fighting for GPU memory on mid-range hardware.
This is the post I wish existed before I started.
The WebGL Context Problem
Browsers limit WebGL contexts to roughly 8–16 per page. Each `<Canvas>` in React Three Fiber creates one. When you exceed the limit, the browser starts destroying old contexts — silently, with no warning — and your scenes go blank.
More practically: even below the limit, each context maintains its own GPU memory pool, shader compilation cache, and state machine. Three simultaneous contexts on a mid-range GPU means three separate texture caches, three sets of compiled shaders, three draw call queues.
**The fix**: Consolidate. One canvas per page where possible. Use a single R3F `<Canvas>` and compose scenes with portals when you need multiple "views."
```tsx
// ❌ Three separate contexts — expensive
<Canvas> <HeroScene /> </Canvas>
<Canvas> <BackgroundScene /> </Canvas>
<Canvas> <FooterScene /> </Canvas>
// ✅ One context, composed
<Canvas>
<HeroScene />
<BackgroundScene />
</Canvas>
// Footer scene: defer mount with IntersectionObserver
```
Instancing: The Single Biggest Win
If you're rendering the same geometry multiple times — particles, stars, a grid of cubes — you should almost always be using instanced meshes.
Normal rendering: N draw calls for N objects.
Instanced rendering: 1 draw call for N objects.
```tsx
// ❌ 500 separate meshes = 500 draw calls
{stars.map((s, i) => (
<mesh key={i} position={s.position}>
<sphereGeometry args={[0.01, 4, 4]} />
<meshBasicMaterial color="white" />
</mesh>
))}
// ✅ 1 instanced mesh = 1 draw call
const mesh = useRef()
useEffect(() => {
const matrix = new THREE.Matrix4()
stars.forEach((s, i) => {
matrix.setPosition(s.x, s.y, s.z)
mesh.current.setMatrixAt(i, matrix)
})
mesh.current.instanceMatrix.needsUpdate = true
}, [])
<instancedMesh ref={mesh} args={[null, null, 500]}>
<sphereGeometry args={[0.01, 4, 4]} />
<meshBasicMaterial color="white" />
</instancedMesh>
```
In the starfield I built for this portfolio, switching from 5000 individual Points to a single instanced setup dropped GPU time by ~60%.
Texture Compression
Uncompressed textures are a silent killer. A 2048×2048 RGBA texture is 16MB in GPU memory. Load five of them and you've consumed 80MB before rendering a single frame.
GPU-native compressed formats (KTX2, Basis) provide 4–8× compression with negligible visual loss:
```tsx
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader'
const loader = new KTX2Loader()
.setTranscoderPath('/basis/')
.detectSupport(gl)
const texture = await loader.loadAsync('/textures/earth.ktx2')
```
The Three.js Draco and KTX2 loaders handle this well. The tooling overhead at build time is real, but the runtime gain is worth it for any texture above 512×512.
Geometry Pooling
Three.js geometries are expensive to create and not garbage collected until explicitly disposed. If you're creating geometries inside components that mount/unmount frequently, you're leaking GPU memory.
```tsx
// ❌ New geometry on every render
function Particle({ position }) {
return (
<mesh position={position}>
<sphereGeometry args={[0.05, 8, 8]} />
<meshBasicMaterial />
</mesh>
)
}
// ✅ Shared geometry reference
const SPHERE_GEO = new THREE.SphereGeometry(0.05, 8, 8)
function Particle({ position }) {
return (
<mesh position={position} geometry={SPHERE_GEO}>
<meshBasicMaterial />
</mesh>
)
}
```
DPR Control
Device Pixel Ratio is perhaps the single highest-leverage knob. A Retina display at DPR=3 means 9× the pixels of DPR=1. For a 1440p monitor, that's rendering at 4320p equivalent.
```tsx
<Canvas
dpr={[1, 1.5]} // Never exceed 1.5x — imperceptible difference above this
performance={{ min: 0.5 }} // Allow R3F to drop quality under load
>
```
The `performance.min` setting is underused. It allows React Three Fiber to dynamically reduce the DPR when FPS drops — essentially adaptive resolution, like console games have done for years.
Defer Everything Below the Fold
The Earth canvas in this portfolio's footer section was killing performance on first load — loading a 3D model, compiling shaders, building BVH acceleration structures — all before the user had scrolled anywhere near it.
The fix is an IntersectionObserver that only mounts the Canvas when the user is within 400px of the section:
```tsx
const [earthMounted, setEarthMounted] = useState(false)
const ref = useRef(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) setEarthMounted(true) },
{ rootMargin: '400px' }
)
observer.observe(ref.current)
return () => observer.disconnect()
}, [])
return (
<section ref={ref}>
{earthMounted && <EarthCanvas />}
</section>
)
```
The Real Lesson
The browser GPU pipeline has no magic. Every pixel you ask it to draw costs something. Every texture you upload occupies memory. Every shader you compile takes time.
The web 3D ecosystem has gotten dramatically better — R3F, Drei, and the Three.js ecosystem make it easy to build impressive things. The flip side is that it's also very easy to build impressively slow things without realizing it.
Profile first. Assume nothing. And DPR clamp to 1.5.
---
*Built with React Three Fiber 8.16, Three.js 0.165, and increasingly strong opinions about WebGL context management.*