Navigate

Search topics across all sections

GitHub
Performance

Optimization Techniques

Your scene runs perfectly on your beefy desktop. Then you open it on a phone and it crawls at 12 frames per second. WebGL performance comes down to three things: fewer draw calls, less wasted work, and never fighting React's render cycle.

You build a forest scene with 500 individual tree meshes. Each tree is a separate React component with its own geometry and material. On your desktop it runs fine, but the performance monitor shows 500 draw calls and the scene stutters on any device below a gaming laptop.

terminal
Performance: 500 draw calls | Frame time: 42ms (24 FPS) | GPU: 95% utilization on mobile

Real-world

Think of GPU optimization like packing a suitcase for a trip.

You have a limited amount of space (your GPU budget). Every item you throw in is a draw call -- a round trip between the CPU and GPU. The key is to pack smart:

Share clothes (reuse materials across objects). Fold compact (use instancing to pack 500 trees into one suitcase slot). Remove what you do not need (frustum culling hides objects outside the camera view). And do not repack every second (avoid React re-renders on every frame).

500 Trees

500 draw calls

Instancing

Batch into one

1 Draw Call

Same visual result

The Three Pillars of R3F Performance

Every optimization falls into one of three categories. Master these and you can get any scene running smoothly.

Pillar 1: Reduce draw calls

InstancedRendering.tsxTSX
// Instancing: 500 meshes become 1 draw call
<instancedMesh args={[undefined, undefined, 500]}>
  <boxGeometry args={[0.12, 0.12, 0.12]} />
  <meshStandardMaterial color="green" />
</instancedMesh>

Every separate mesh costs at least one draw call -- a CPU-GPU round trip. Instancing tells the GPU to draw the same geometry many times with different transforms in a single call. If you have 10+ copies of the same shape, always instance them.

Pillar 2: Stop fighting React

PreventRerenders.tsxTSX
// Direct mutation = zero re-renders
const ref = useRef<THREE.Mesh>(null)
useFrame((_, delta) => {
  ref.current!.rotation.y += delta
})

The biggest mistake in R3F is storing animation values in React state. Every setState inside useFrame triggers a full component re-render 60 times per second. Use refs to mutate Three.js objects directly. React never knows the value changed, and that is exactly what you want.

Pillar 3: Measure before optimizing

Profiling.tsxTSX
import { Perf } from 'r3f-perf'

// Only in development — shows FPS, draw calls, triangles
{process.env.NODE_ENV === 'development' && (
  <Perf position="top-left" />
)}

Never optimize blindly. Drop r3f-perf into your scene and it shows real-time FPS, draw calls, triangle count, and GPU memory. Aim for under 100 draw calls, under 16.6ms frame time (60 FPS), and cap pixel ratio with dpr={[1, 2]}.

What you just learned

Each separate mesh costs at least one draw call. Instancing can reduce 500 draw calls to 1.

Never store per-frame animation values in React state. Use refs for direct Three.js mutation.

Use visible={false} instead of conditional rendering to toggle expensive components without destroying GPU resources.

alphaTest is much cheaper than transparent for cutout-style textures (foliage, fences).

Always measure with r3f-perf before optimizing. Target: <100 draw calls, <16.6ms frame time.

Question

You have a scene with 200 identical cubes. A teammate suggests merging them all into one BufferGeometry. Another suggests using instancedMesh. When would you pick one over the other?

Think about it...

You have a scene with 100 identical spheres, and your r3f-perf shows 100 draw calls. You switch to instancedMesh and the draw calls drop to 1. But then you need 50 of those spheres to use a different material. How many draw calls will you have now?

Hint: Each instancedMesh can only have one geometry and one material. How many unique materials do you have?

Try These Challenges

Put what you learned into practice. Try each challenge in the demo above using the Leva controls, then check the solution.

Try This!

Beginner

Set count to 500 — feel the lag!

Try This!

Beginner

Set spacing to 1 — dense cluster

Try This!

Beginner

Toggle to instanced only — smooth!

These are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.

1
Using React state for per-frame animation values
setState triggers 60 re-renders per second
Don't do this
AnimationState.tsxTSX
function Spinner() {
  const [rot, setRot] = useState(0)
  useFrame((_, delta) => {
    setRot((r) => r + delta) // Re-render every frame!
  })
  return <mesh rotation-y={rot} />
}
Calling setState inside useFrame triggers a full React re-render 60 times per second. React must reconcile the entire component tree on every frame. Use refs to directly mutate Three.js properties without involving React. Reserve setState for discrete events like clicks or toggles.
2
Mounting/unmounting expensive components
Conditional rendering destroys and recreates GPU resources
Don't do this
ToggleVisibility.tsxTSX
// Mounts/unmounts the forest, recreating geometry
{showForest && <Forest />}
Conditionally rendering a complex component destroys all Three.js objects (geometry, materials, textures) and recreates them on mount. This causes a visible frame hitch. Setting visible=false skips rendering without destroying anything, making the toggle nearly free. The tradeoff is that hidden objects still use GPU memory.
3
Using transparency when alphaTest would work
Transparent objects require expensive sorting
Don't do this
AlphaTest.tsxTSX
// Transparent: requires sorting, double rendering
<meshStandardMaterial
  map={foliageTexture}
  transparent={true}
/>
Transparent materials force the GPU to sort objects back-to-front and can cause visual artifacts with overlapping surfaces. For textures with fully opaque and fully transparent areas (foliage, fences, particles), use alphaTest instead. It discards pixels below the threshold, treating the rest as fully opaque. No sorting needed.

Best Practices

Limit Pixel Ratio

Set dpr={[1, 2]} on Canvas to cap the pixel ratio at 2. High-DPI screens (3x, 4x) render 9 to 16 times more pixels for minimal visual improvement. This is often the single biggest performance win on mobile.

Demand Rendering for Static Scenes

For scenes that rarely change, use frameloop="demand" on Canvas. This renders only when something changes, saving GPU and battery. OrbitControls auto-invalidates on camera movement.

Bake Static Lighting

For scenes with fixed lighting, bake light and shadow into textures in Blender. This eliminates real-time shadow calculations, which are one of the most expensive parts of rendering.

Toggle Visibility, Not Mounting

Use visible=false instead of conditional rendering for expensive components. Mounting and unmounting recreates all GPU resources. Visibility toggling is nearly free and avoids frame hitches.