A Three.js scene that runs smoothly on a developer’s laptop can stutter, drain batteries and overheat phones once real users load it. Three.js performance optimization is mostly about doing less work per frame and sending less data to the GPU, and this guide walks through the techniques that matter most, in roughly the order I reach for them. Every section includes code you can drop into a plain Three.js or react-three-fiber project.
Measure before you optimise
Guessing wastes time. Before changing anything, find out whether you are limited by the CPU (too many draw calls, too much JavaScript per frame) or by the GPU (too many pixels, heavy shaders, big shadow maps).
- renderer.info tells you how many draw calls and triangles the last frame used, and how many geometries, textures and shader programs are alive in memory.
- stats.js (shipped with Three.js) shows frame time and FPS in a small overlay.
- Chrome DevTools Performance panel shows where frame time goes: long scripting blocks point to CPU work, while a busy GPU track with little scripting points to fill rate or shader cost.
import Stats from 'three/addons/libs/stats.module.js';
const stats = new Stats();
document.body.appendChild(stats.dom);
function logInfo() {
const { render, memory, programs } = renderer.info;
console.table({
drawCalls: render.calls,
triangles: render.triangles,
geometries: memory.geometries,
textures: memory.textures,
programs: programs?.length ?? 0,
});
}
Watch memory.geometries and memory.textures as you navigate your app. If they only ever go up, you have a leak (more on that below). Always test on a mid-range phone, not just a desktop GPU.
Cap the device pixel ratio
Many phones report a devicePixelRatio of 3. Rendering at that ratio means nine times the pixels of a ratio of 1, and the visual gain over 2 is hard to see. Capping it is the cheapest win in Three.js:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
For heavy scenes, consider lowering the cap to 1.5 on mobile, or adjusting it dynamically when frame time climbs.
Render on demand instead of a constant loop
Most tutorials start a requestAnimationFrame loop that renders 60 or more times a second forever. If your scene is static until the user interacts, such as a product viewer or a model inspector, that loop burns power for nothing. Render only when something changes:
let renderRequested = false;
function render() {
renderRequested = false;
controls.update();
renderer.render(scene, camera);
}
function requestRender() {
if (!renderRequested) {
renderRequested = true;
requestAnimationFrame(render);
}
}
controls.addEventListener('change', requestRender);
window.addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
requestRender();
});
If you use enableDamping on OrbitControls, the controls keep emitting change events while they ease to a stop, so this pattern still works.
Pause when off-screen or the tab is hidden
Browsers throttle requestAnimationFrame in background tabs, but a canvas scrolled out of view on a long page keeps rendering. Use an IntersectionObserver to stop the loop when the canvas is not visible, and the visibilitychange event for hidden tabs:
const observer = new IntersectionObserver(([entry]) => {
renderer.setAnimationLoop(entry.isIntersecting ? animate : null);
});
observer.observe(renderer.domElement);
document.addEventListener('visibilitychange', () => {
if (document.hidden) renderer.setAnimationLoop(null);
});
Dispose of what you no longer need
Removing a mesh from the scene does not free its GPU memory. Geometries, materials and textures must be disposed explicitly, otherwise single-page apps that swap models slowly run out of memory.
function disposeObject(root) {
root.traverse((obj) => {
if (!obj.isMesh) return;
obj.geometry.dispose();
const materials = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const mat of materials) {
for (const value of Object.values(mat)) {
if (value && value.isTexture) value.dispose();
}
mat.dispose();
}
});
root.removeFromParent();
}
Be careful with shared resources: if two meshes use the same texture, disposing it for one breaks the other. Track ownership, or dispose only when you are tearing down the whole scene.
Cut draw calls with instancing, batching and merging
Every visible mesh with its own material is at least one draw call, and draw calls are CPU-bound. A few hundred is usually fine; thousands will hurt on mobile. Three tools help.
InstancedMesh for many copies of one object
Trees, bolts, chairs or bricks that share geometry and material can be drawn in a single call:
const mesh = new THREE.InstancedMesh(geometry, material, 1000);
const matrix = new THREE.Matrix4();
for (let i = 0; i < 1000; i++) {
matrix.setPosition(Math.random() * 50, 0, Math.random() * 50);
mesh.setMatrixAt(i, matrix);
}
mesh.instanceMatrix.needsUpdate = true;
mesh.computeBoundingSphere(); // keeps frustum culling correct
scene.add(mesh);
BatchedMesh for different geometries, one material
BatchedMesh draws many different geometries that share a material in one call, with per-instance transforms and visibility. It suits scenes built from a kit of parts, such as furniture or construction components.
Merging static geometry
For static objects that never move independently, mergeGeometries from three/addons/utils/BufferGeometryUtils.js combines them into one geometry. The trade-off is that you lose per-object culling and interaction, so merge by area or by material, not the entire world.
Use level of detail (LOD)
Objects far from the camera do not need full-resolution meshes. THREE.LOD switches between versions based on distance:
const lod = new THREE.LOD();
lod.addLevel(highDetailMesh, 0);
lod.addLevel(mediumDetailMesh, 20);
lod.addLevel(lowDetailMesh, 60);
scene.add(lod);
Generate the lower levels offline with a simplifier (for example meshoptimizer’s simplification, available through gltf-transform or gltfpack) rather than at runtime.
Compress textures and models
Textures are usually the biggest memory cost. A PNG or JPEG is small on disk but is decoded to raw RGBA in GPU memory, so a 4096 x 4096 texture takes roughly 64 MB before mipmaps, whatever the file size.
- Right-size textures. Use the smallest resolution that looks good at the size the object appears on screen. Power-of-two dimensions are still a sensible default.
- Use KTX2 with Basis Universal. KTX2 textures stay compressed on the GPU, which cuts memory use and upload time. Load them with
KTX2Loader. - Compress geometry with Draco or Meshopt. Both shrink glTF downloads significantly. Meshopt decodes quickly and also compresses animation data; Draco often produces smaller meshes. Test both on your models.
npx @gltf-transform/cli optimize model.glb model.opt.glb
--compress meshopt --texture-compress ktx2
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
const ktx2 = new KTX2Loader()
.setTranscoderPath('/basis/')
.detectSupport(renderer);
const loader = new GLTFLoader()
.setKTX2Loader(ktx2)
.setMeshoptDecoder(MeshoptDecoder);
KTX2 encoding through gltf-transform needs the KTX-Software tools installed locally. Copy the Basis transcoder files from three/examples/jsm/libs/basis/ into your public folder.
Be deliberate with lights and shadows
Every real-time light adds shader work for every lit pixel, and every shadow-casting light renders the scene again into a shadow map. A few practical rules:
- Prefer image-based lighting (an environment map on
scene.environment) plus one directional light over many point lights. - Enable
castShadowandreceiveShadowonly on meshes that need them. - Keep
shadow.mapSizemodest and tighten the shadow camera bounds so resolution is not wasted. - For static scenes, set
renderer.shadowMap.autoUpdate = falseand setrenderer.shadowMap.needsUpdate = trueonly when something moves. - Bake lighting and ambient occlusion into textures in Blender when nothing moves.
Let frustum culling work for you
Three.js skips objects outside the camera’s view, but only when their bounding volumes are correct. If you move vertices in a shader or update instance matrices, recompute bounding spheres, otherwise objects can vanish or be drawn when off-screen. Avoid setting frustumCulled = false as a quick fix. Splitting one huge mesh into sensible chunks also lets culling skip the parts you cannot see.
react-three-fiber specifics
Everything above applies to react-three-fiber (R3F), plus a few React-specific points.
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { useRef } from 'react';
export function Viewer() {
return (
<Canvas frameloop="demand" dpr={[1, 2]}>
<Spinner />
</Canvas>
);
}
function Spinner() {
const ref = useRef();
const invalidate = useThree((state) => state.invalidate);
useFrame((state, delta) => {
ref.current.rotation.y += delta; // mutate, never setState
});
return (
<mesh ref={ref} onClick={() => invalidate()}>
<boxGeometry />
<meshStandardMaterial color="orange" />
</mesh>
);
}
- frameloop=”demand” renders only when props change or you call
invalidate(). Note thatuseFrameonly runs on rendered frames in this mode, so continuous animations needinvalidate()each frame while they run. - dpr={[1, 2]} clamps the pixel ratio.
- Never call setState inside useFrame. It re-renders React components up to 60 times a second. Mutate refs directly instead.
- Reuse geometries and materials with
useMemo, or declare them once and share them, rather than creating new ones per component. - Drei offers ready-made helpers:
<Instances>,<Detailed>for LOD,<PerformanceMonitor>and<AdaptiveDpr>for quality scaling.
R3F disposes objects it created when they unmount, but assets loaded through useGLTF are cached for reuse, so clear that cache yourself if you load many different models in one session.
Key takeaways
- Measure with
renderer.info, stats.js and DevTools on a real mid-range phone. - Cap the pixel ratio at 2 or lower.
- Render on demand and pause off-screen canvases.
- Dispose geometries, materials and textures when you remove them.
- Reduce draw calls with InstancedMesh, BatchedMesh and merging.
- Use LOD for distant objects.
- Ship glTF with Meshopt or Draco, and KTX2 textures at sensible sizes.
- Limit shadow-casting lights and bake what does not move.
- In R3F, use
frameloop="demand"and never set state inuseFrame.
Frequently asked questions
Why is my Three.js scene slow on mobile but fine on desktop?
Phones have high pixel ratios and much weaker GPUs, so they render far more pixels with less power. Capping the device pixel ratio, compressing textures and reducing draw calls usually fixes most of the gap.
How many draw calls are too many in Three.js?
There is no fixed number because it depends on the device and the shaders, but a few hundred is generally safe on mobile while thousands often cause frame drops. Check renderer.info.render.calls and use instancing or batching when the count climbs.
Should I use Draco or Meshopt compression for glTF?
Both reduce download size. Meshopt decodes faster and supports animation data, while Draco can produce smaller static meshes, so compress your actual models with each and compare file size and load time.
Does removing a mesh from the scene free memory?
No. You must call dispose() on its geometry, materials and textures to release GPU memory, while making sure no other mesh still uses those resources.
These are the same techniques I use in production, including on an AI SaaS that renders 3D build guides in the browser. If you are building a 3D product viewer, my guide to building a 3D product configurator with Three.js covers the pipeline end to end. If your Three.js app needs to be faster, get in touch and let’s talk about it.