Three.js performance optimization: measure first, then fix
The Glitchless team · October 27, 2026 · 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
- Too many draw calls: draw repeated objects with one
InstancedMeshand share geometries and materials - Slower the longer you play:
dispose()geometries, materials and textures when you remove them, or pool them - A hitch the first time something appears: precompile with
renderer.compileAsync()during loading, and keep the number of lights constant - Shadows that cost too much: cast shadows from one light, on the objects that need them, with a tight shadow camera
- Fine on desktop, slow on phones: cap the pixel ratio, and cut full-screen effects and overdraw
- 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
// 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
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
// 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
// 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
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
// 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 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
- three.js docs: WebGLRenderer.info
- three.js manual: How to dispose of objects
- three.js docs: InstancedMesh
- three.js manual: Responsive design (pixel ratio)
- React Three Fiber: Performance pitfalls
Something wrong or out of date? Email support@glitchless.gg and we'll fix it. Glitchless isn't affiliated with the three.js authors; product names are their owners' trademarks.