# Three.js performance optimization: measure first, then fix

> How to make a Three.js or React Three Fiber game run faster: measure with renderer.info and frame times, then cut draw calls with InstancedMesh, dispose what you remove, precompile shaders with compileAsync, and tame shadows, pixel ratio and per-frame allocations. With code you can copy.

Website: https://www.glitchless.gg/threejs-performance-optimization.html · three.js r160 and later, and React Three Fiber

Most slow Three.js games are slow for one of a handful of reasons: too many draw calls, objects that never get freed, shaders compiling mid-game, expensive shadows, too many pixels, or garbage made every frame. Each one shows up in numbers you can read in a few lines of code, so measure before you change anything.

## The short answer

1. **Too many draw calls**: draw repeated objects with one `InstancedMesh` and share geometries and materials
2. **Slower the longer you play**: `dispose()` geometries, materials and textures when you remove them, or pool them
3. **A hitch the first time something appears**: precompile with `renderer.compileAsync()` during loading, and keep the number of lights constant
4. **Shadows that cost too much**: cast shadows from one light, on the objects that need them, with a tight shadow camera
5. **Fine on desktop, slow on phones**: cap the pixel ratio, and cut full-screen effects and overdraw
6. **Hitches every few seconds**: stop allocating objects in the render loop

## First, measure: renderer.info and frame times

`renderer.info` counts what the renderer did: draw calls and triangles in the last frame, and the geometries, textures and shader programs alive on the GPU. Log it every few seconds while you play, together with frame times. Report the typical frame, the 95th percentile and the worst frame, not average FPS, which hides hitches.

Then read the shape. Draw calls in the thousands from the start: too many separate meshes. Draw calls or geometries that keep climbing: something is never removed or freed. A spike the first time something appears, with `programs` going up: a shader compiling. Slow everywhere on phones but fine on desktop: pixels and shadows.

If you render more than once a frame (post-processing, a minimap), set `renderer.info.autoReset = false` and call `renderer.info.reset()` once per frame yourself, or `calls` only shows the last pass. On `WebGPURenderer` the names differ: draw calls are `info.render.drawCalls` and programs are `info.memory.programs`. Chrome's Performance panel shows which function a long frame spent its time in, and Spector.js captures every draw call of one frame.

perf-log.js:

```js
// Logs frame times and renderer.info every 5 seconds. Call tick() once per frame.
const frames = [];
let last = performance.now();
let total = 0;

export function tick(renderer) {
  const now = performance.now();
  frames.push(now - last);
  total += now - last;
  last = now;
  if (total < 5000) return;
  frames.sort((a, b) => a - b);
  const at = (q) => frames[Math.min(frames.length - 1, Math.floor(frames.length * q))].toFixed(1);
  const { render, memory, programs } = renderer.info;
  console.log(`frame ms: typical ${at(0.5)}, 95th ${at(0.95)}, worst ${at(1)} | draw calls ${render.calls}, triangles ${render.triangles}, geometries ${memory.geometries}, textures ${memory.textures}, programs ${programs?.length}`);
  frames.length = 0;
  total = 0;
}
```

## Too many draw calls

**What you see.** Thousands of draw calls from the start, and a game that's slow on every device, worst on phones and integrated GPUs.

**Why.** Every mesh is at least one draw call, two or more if it casts shadows, and each call has a fixed CPU cost. A forest of separate trees, coins or bullets turns into thousands of calls even if each one is tiny.

Draw copies of the same thing with one `InstancedMesh` and update each copy's matrix, or `THREE.Points` for particles. One draw call then covers all of them.

Share one geometry and one material between copies; never create them per object. Merge static scenery that never moves into fewer meshes with `BufferGeometryUtils.mergeGeometries()`.

instanced-coins.js:

```js
import * as THREE from 'three';

// 2,000 coins in one draw call instead of 2,000.
const coins = new THREE.InstancedMesh(
  new THREE.CylinderGeometry(0.3, 0.3, 0.05, 24),
  new THREE.MeshStandardMaterial({ color: 0xffc83d }),
  2000,
);
const m = new THREE.Matrix4();
for (let i = 0; i < coins.count; i++) {
  m.setPosition(Math.random() * 200 - 100, 1, Math.random() * 200 - 100);
  coins.setMatrixAt(i, m);
}
coins.instanceMatrix.needsUpdate = true;
scene.add(coins);
```

## Slower the longer you play

**What you see.** `renderer.info.render.calls` or `memory.geometries` keeps climbing as you play or change levels, and the game gets slower and uses more memory.

**Why.** `scene.remove()` takes an object out of the scene but frees nothing on the GPU. An effect faded to opacity 0 is still drawn. Levels that build fresh geometry and materials on every load pile them up.

When an effect ends, remove it and free what it alone owned. Better still, keep a fixed set of effect objects and reuse them.

On a level change, traverse what you unload and dispose every geometry, material and texture that isn't shared. `memory.geometries` and `memory.textures` should come back to the same number each time.

dispose.js:

```js
// Free everything an object owned on the GPU. Skip shared geometries, materials and textures.
export function disposeDeep(root) {
  root.traverse((obj) => {
    obj.geometry?.dispose();
    for (const mat of [].concat(obj.material ?? [])) {
      for (const value of Object.values(mat)) if (value?.isTexture) value.dispose();
      mat.dispose();
    }
  });
  root.removeFromParent();
}
```

## A hitch the first time something appears

**What you see.** A freeze the first time an effect, material or enemy shows up, with `renderer.info.programs` going up in that frame, and no freeze the next time.

**Why.** Three.js compiles a material's shader program the first frame it's drawn, and the frame waits for it. Lights are compiled into lit materials' shaders, so adding or removing a light recompiles every lit material in the scene.

Put everything the level uses in the scene while the loading screen is up, with its final lights and shadows, then call `await renderer.compileAsync(scene, camera)` (three.js r158 and later) before play starts. It compiles hidden and off-screen objects too, but it only counts lights that are visible, and on `WebGLRenderer` the shadow depth shaders still compile on the first frame with shadows. Upload big textures ahead of time as well with `renderer.initTexture(texture)`.

Keep the number of lights the same during play. Toggling `light.visible` or `castShadow` counts as a change too, so leave lights in the scene and set `intensity` to 0 instead, move a small pool of lights to where they're needed, or use emissive materials and glow sprites instead of a light per object.

load-level.js:

```js
// While the loading screen is showing:
scene.add(level, ...effects);      // everything the level will draw, effects included
await renderer.compileAsync(scene, camera);
hideLoadingScreen();
```

## Shadows that cost too much

**What you see.** Turning shadows off makes a big difference, or frame time grows with every shadow-casting light you add.

**Why.** Each shadow-casting light draws the scene again into its shadow map, every frame. Point lights draw it six times.

Cast shadows from one directional light. Set `castShadow` only on objects that need a shadow, keep `shadow.mapSize` at 1024 or 2048, and fit the shadow camera tightly around the play area. If the scene doesn't move, render the shadow map once with `renderer.shadowMap.autoUpdate = false` and set `renderer.shadowMap.needsUpdate = true` when something changes.

## Fine on desktop, slow on phones

**What you see.** Smooth on your laptop, slow on phones and high-resolution screens, and faster when the window is smaller.

**Why.** Phones report a `devicePixelRatio` of 3, which is nine times the pixels of 1. Big transparent layers, post-processing and overlapping particles cost something for every one of those pixels.

Limit how many pixels you draw. The common way is to cap the pixel ratio, as below; the three.js manual's own approach is to size the canvas yourself and cap the drawing buffer's total pixels, which also handles fractional screen scaling. Either way, go lower on weak devices, and cut post-processing passes and large transparent planes on phones first.

renderer.js:

```js
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));

// React Three Fiber: <Canvas dpr={[1, 1.5]}>
```

## Hitches every few seconds

**What you see.** Small hitches at a steady rhythm, and the JavaScript heap in Chrome's Memory or Performance panel rising and dropping like a saw.

**Why.** `new THREE.Vector3()`, arrays and closures made every frame become garbage, and the browser pauses to collect it.

Create vectors, quaternions and matrices once outside the loop and reuse them. In React Three Fiber, keep them in `useMemo` or module scope instead of making them in `useFrame`, and change refs instead of calling `setState` every frame.

follow.js:

```js
// Made once, reused every frame.
const target = new THREE.Vector3();

function follow(camera, player, dt) {
  target.copy(player.position).add(OFFSET);
  camera.position.lerp(target, 1 - Math.exp(-8 * dt));
}
```

## Check the fix

Run the same scene the same way before and after, with the logger above, and compare the typical, 95th percentile and worst frames and the draw call and geometry counts. Test on the slowest device you support, not only your own machine: a phone that's fine for a minute can slow down once it heats up.

## What your players' devices do

Your laptop isn't your players' phones. A scene that holds 60 fps for you can drop to 30 on a mid-range Android phone, and a shader that compiles in a few milliseconds on your GPU can freeze theirs.

[Glitchless](https://www.glitchless.gg/index.md) is an open-source SDK for Three.js and React Three Fiber (and Phaser, Babylon.js and PlayCanvas) that records frame times, hitches with their likely cause (a shader compiling, textures uploading, garbage collection) and the `renderer.info` counts from every player's session, split by device, browser and version. Your coding agent reads the same data, and with the game open in a browser it can measure the scene directly and check its own fix.

Ask your coding agent:

```
Use Glitchless to find why my Three.js game is slow for players: show the worst levels by 95th percentile frame time with their draw calls and hitch causes, fix the top problem, and measure the scene again to show the difference.
```

## Frequently asked questions

### Is Three.js itself slow?

Rarely. Three.js passes your scene to WebGL with little overhead; slow games usually draw too many separate meshes, never free what they remove, or ask a phone to fill too many pixels. `renderer.info` tells you which.

### How many draw calls is too many?

It depends on the device, but a few hundred is comfortable on most phones and thousands is trouble on most. Watch the count while you play: it matters more that it stays flat than what the exact number is.

### Does WebGPU fix performance?

It lowers the cost of each draw call and adds compute shaders, but too many meshes, leaks and overdraw still cost the same. Fix those first, in either renderer.

### How do I find what's slow in React Three Fiber?

The same way: read `gl.info` (the renderer) and frame times. R3F-specific causes are allocations inside `useFrame`, `setState` every frame (which re-renders React), and many separate `<mesh>` elements where `<instancedMesh>` or drei's `<Instances>` would do.

## Sources

- [three.js docs: WebGLRenderer.compileAsync](https://threejs.org/docs/#WebGLRenderer.compileAsync)
- [three.js docs: WebGLRenderer.info](https://threejs.org/docs/#WebGLRenderer.info)
- [three.js manual: How to dispose of objects](https://threejs.org/manual/#how-to-dispose-of-objects)
- [three.js docs: InstancedMesh](https://threejs.org/docs/#InstancedMesh)
- [three.js manual: Responsive design (pixel ratio)](https://threejs.org/manual/#responsive)
- [React Three Fiber: Performance pitfalls](https://r3f.docs.pmnd.rs/advanced/pitfalls)
