← All articles
Three.js Performance Optimization: A Practical Guide
Three.js performance optimization that actually moves the FPS: cut draw calls, batch geometry, dispose memory, and render on demand in React Three Fiber.

The first time a client opened my WebGL hero on a three-year-old laptop, it ran at 18 FPS and their fan sounded like a hairdryer. The scene looked great on my M-series Mac — that's the trap. Three.js performance optimization is the difference between a demo that wins an award and a site that gets closed before it loads. After shipping a dozen 3D sites, the wins are almost always the same handful of things, in the same order. This is the checklist I actually use, with the numbers and the gotchas.
The single most important idea: your GPU doesn't care how many triangles you have nearly as much as it cares how many times you ask it to draw. Most slow Three.js scenes aren't triangle-bound — they're draw-call-bound, memory-bound, or rendering every frame when nothing moved.
Measure first: renderer.info is your source of truth
Never optimize blind. Before touching anything, read the numbers the renderer already gives you:
function logStats(renderer: THREE.WebGLRenderer) {
const { render, memory, programs } = renderer.info
console.table({
drawCalls: render.calls,
triangles: render.triangles,
geometries: memory.geometries,
textures: memory.textures,
programs: programs?.length ?? 0,
})
}
// call once per second, not every frame
My rough budget for a smooth 60 FPS on mid-range hardware: under ~100–150 draw calls, triangles under a couple million, and a flat geometry/texture count over time (if it climbs while idle, you have a leak). Pair this with the browser's GPU profiler and stats.js for a live FPS readout. Optimize the biggest number first — guessing is how you spend a day shaving 2% off something that didn't matter.
1. Kill draw calls — instancing and batching
Every unique mesh is at least one draw call. A forest of 2,000 trees rendered as 2,000 meshes is a death sentence. Two tools fix this:
InstancedMesh — one geometry, one material, thousands of transforms, one draw call:
const count = 2000
const mesh = new THREE.InstancedMesh(geometry, material, count)
const dummy = new THREE.Object3D()
for (let i = 0; i < count; i++) {
dummy.position.set(
(Math.random() - 0.5) * 100, 0, (Math.random() - 0.5) * 100,
)
dummy.rotation.y = Math.random() * Math.PI
dummy.updateMatrix()
mesh.setMatrixAt(i, dummy.matrix)
}
mesh.instanceMatrix.needsUpdate = true
BatchedMesh (Three.js r159+, much improved in r170) is the newer trick when your objects share a material but have different geometries — it merges them into a single multi-draw call. Use InstancedMesh for identical geometry, BatchedMesh for varied geometry with one material. Together they can collapse hundreds of draw calls into single digits.
If geometry is static and you don't need per-object control, merge it offline instead:
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"
const merged = mergeGeometries([geoA, geoB, geoC])
In React Three Fiber, drei's <Instances>/<Instance> and <Merged> give you the same wins declaratively — see my React Three Fiber tutorial for beginners for the component patterns.
2. Render on demand — stop drawing static frames
A huge amount of wasted GPU goes to re-rendering a scene that hasn't changed. If your 3D is mostly static (a product viewer, a hero you only animate on scroll), switch off the continuous loop.
In React Three Fiber, set frameloop="demand" and call invalidate() when something actually changes:
import { Canvas, useThree } from "@react-three/fiber"
<Canvas frameloop="demand">
<Scene />
</Canvas>
// re-render only when you mutate the scene
const invalidate = useThree((s) => s.invalidate)
useEffect(() => { invalidate() }, [someState, invalidate])
drei's OrbitControls and animated springs call invalidate() for you. On one product-configurator I shipped, frameloop="demand" dropped idle GPU usage to near zero and stopped laptops from spinning up — the single biggest battery/thermal win available, for almost no code.
For vanilla Three.js, just don't call renderer.render() in the RAF loop unless a flag says the scene is dirty.
3. Cap the pixel ratio — the one-line 2× win
Retina displays report devicePixelRatio of 2 or 3, which means you're rendering 4–9× the pixels. Almost nobody can tell the difference past 2:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
In R3F: <Canvas dpr={[1, 2]} />. This is frequently the highest-ROI single line in the entire codebase — fragment-shader-heavy scenes (anything with post-processing or fancy materials) are fill-rate bound, and you just halved the work.
4. Compress textures and geometry — the real payload
Textures are usually the biggest part of the download and of GPU memory. A 4096×4096 PNG can be 8–10 MB on disk and ~64 MB decompressed in VRAM. Two non-negotiables:
- GPU-compressed textures (KTX2 / Basis) stay compressed in VRAM, not just on the wire. Use
KTX2Loader. A texture that was 64 MB uncompressed can sit at ~4 MB in memory. - Draco or Meshopt for geometry. Draco can shrink a glTF mesh by 80–90% on the wire (it costs a little decode time — worth it almost always).
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"
const draco = new DRACOLoader().setDecoderPath("/draco/")
const loader = new GLTFLoader().setDRACOLoader(draco)
Run your .glb through gltf-transform or gltfpack once at build time — it Dracos the mesh, generates KTX2 textures, and prunes unused data in a single command. This is offline work that pays back on every single visit.
5. Dispose everything — the leak that kills SPAs
Three.js does not garbage-collect GPU resources for you. In a single-page app where you mount/unmount scenes (React route changes, modals), undisposed geometries, materials, and textures leak VRAM until the context crashes. Watch renderer.info.memory — if it only ever goes up, you have this bug.
function disposeObject(obj: THREE.Object3D) {
obj.traverse((node) => {
const mesh = node as THREE.Mesh
mesh.geometry?.dispose()
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
mats.forEach((m) => {
if (!m) return
for (const key in m) {
const val = (m as any)[key]
if (val?.isTexture) val.dispose()
}
m.dispose()
})
})
}
R3F handles most of this automatically on unmount — but not resources you created imperatively or cached outside the React tree. If you new THREE.Texture() yourself, you dispose it yourself.
6. Materials, lights, and shadows — the quiet cost
- Lights and shadows are expensive. Real-time shadow maps re-render the scene from each light. Bake lighting into textures where you can, keep shadow-casting lights to one, and lower
shadow.mapSizeto 1024 or 512. - Reuse materials and geometries. Two meshes sharing one material can batch; two identical-looking meshes with separate material instances cannot.
- Pick the cheapest material that looks right.
MeshBasicMaterial<MeshLambertMaterial<MeshStandardMaterial<MeshPhysicalMaterialin cost. You rarely need physical.
If you're writing custom GLSL, keep heavy math out of the fragment shader — it runs per pixel, millions of times. The same effect computed in the vertex shader (per vertex) can be orders of magnitude cheaper. See the techniques in my WebGL displacement hover effect guide.
7. Adapt to the device — don't ship one quality tier
The fastest scene is one that scales to the hardware. drei's <PerformanceMonitor> watches average FPS and fires onDecline/onIncline so you can drop DPR, disable post-processing, or thin out instances on weak GPUs:
import { PerformanceMonitor } from "@react-three/drei"
<PerformanceMonitor
onDecline={() => setDpr(1)}
onIncline={() => setDpr(2)}
/>
Always provide a static fallback (a poster image or CSS) for devices that can't handle WebGL at all, and respect prefers-reduced-motion. A site that gracefully degrades beats one that janks for a third of visitors.
Quick reference: what to reach for
| Symptom | First fix | API |
|---|---|---|
| High draw calls | Instance / batch repeated meshes | InstancedMesh, BatchedMesh |
| Janky on Retina, fine on desktop | Cap pixel ratio | setPixelRatio(min(dpr, 2)) |
| Slow even when idle | Render on demand | frameloop="demand", invalidate() |
| Huge download / VRAM | Compress assets | KTX2, Draco, gltf-transform |
| Memory climbs over time | Dispose on unmount | .dispose() on geo/mat/tex |
| Fill-rate bound (post-fx) | Lower DPR, simplify shaders | vertex over fragment math |
How this ties into Core Web Vitals
A heavy .glb blocks the main thread and tanks your LCP and INP. Lazy-load the canvas below the fold, decode models off the critical path, and never let WebGL block first paint. I go deep on the metrics side in Core Web Vitals for animation-heavy sites — 3D performance and Vitals are the same problem viewed from two angles. If you want this done right on a real product, see how I build 3D sites or get in touch about a project.
FAQ
How many draw calls is too many in Three.js?
There's no hard limit, but on mid-range mobile, scenes start to struggle past a few hundred draw calls. Aim for under ~100–150 for a comfortable 60 FPS. Use renderer.info.render.calls to measure, then instance or batch the biggest offenders.
Does reducing triangle count fix a slow Three.js scene?
Usually not first. Most slow scenes are bound by draw calls, fill rate (pixel ratio), or wasted frames — not raw triangle count. Profile with renderer.info before decimating geometry; you'll often find a single texture or an uncapped pixel ratio is the real cost.
Is React Three Fiber slower than vanilla Three.js?
No — R3F is a thin reconciler over Three.js with negligible runtime overhead, and it gives you frameloop="demand", automatic disposal, and drei helpers that make the optimizations above easier. The performance ceiling is the same; you reach it with less code.
What's the difference between InstancedMesh and BatchedMesh?
InstancedMesh renders many copies of one geometry with one material in a single draw call. BatchedMesh (r159+) renders different geometries that share a material in a single multi-draw call. Use instancing for identical objects, batching for varied objects with a shared material.