Your Three.js scene looks incredible in development. Then you test on a laptop and watch the framerate drop to 15fps. On mobile, it's a slideshow. The client demo is tomorrow.
This is the reality of WebGL development. The gap between "works on my machine" and "works everywhere" is filled with optimization work that most tutorials skip over.
Here's how to diagnose performance issues and fix them systematically.
Diagnosing the Problem
Before optimizing, you need to know what's actually slow. Add stats.js to see real-time performance metrics:
import Stats from 'three/examples/jsm/libs/stats.module.js';
const stats = new Stats();
document.body.appendChild(stats.dom);
function animate() {
stats.begin();
// your render loop
renderer.render(scene, camera);
stats.end();
requestAnimationFrame(animate);
}
This shows you three critical numbers:
- FPS - Frames per second. Target: 60. Minimum acceptable: 30.
- MS - Milliseconds per frame. Under 16ms = 60fps. Under 33ms = 30fps.
- MB - Memory usage. Watch for leaks (constantly increasing).
Chrome DevTools Performance Tab
For deeper analysis, use Chrome DevTools:
- Open DevTools (F12)
- Go to Performance tab
- Click Record, interact with your scene, click Stop
- Look for long frames in the timeline
This reveals whether you're CPU-bound (JavaScript taking too long) or GPU-bound (too much geometry/shaders).
Three.js Renderer Info
Check what's actually being rendered:
console.log(renderer.info);
// Shows:
// - render.calls (draw calls per frame)
// - render.triangles (triangles per frame)
// - memory.geometries
// - memory.textures
High draw calls (over 100) or triangle counts (over 1 million) are red flags.
The Four Performance Killers
In order of impact, these are what actually slow down Three.js scenes:
1. Draw Calls
Every mesh with a unique material requires a separate "draw call" to the GPU. Each draw call has overhead - context switching, state changes, buffer binding.
| Draw Calls | Performance | Target Device |
|---|---|---|
| < 50 | Excellent | All devices |
| 50-100 | Good | Desktop + modern mobile |
| 100-200 | Acceptable | Desktop only |
| > 200 | Problematic | High-end desktop only |
Fix: Merge meshes that share materials. Use instancing for repeated objects. Combine textures into atlases.
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
// Before: 100 draw calls
const meshes = objects.map(obj => obj.geometry);
// After: 1 draw call
const mergedGeometry = mergeGeometries(meshes);
const mergedMesh = new THREE.Mesh(mergedGeometry, sharedMaterial);
2. Polygon Count
Every triangle needs to be transformed, lit, and rasterized. More triangles = more work.
| Total Triangles | Performance | Use Case |
|---|---|---|
| < 100K | Fast everywhere | Mobile-first, AR |
| 100K-500K | Good on desktop | Product viewers, games |
| 500K-1M | Desktop only | Architectural viz |
| > 1M | Needs LOD system | Film, high-end |
Fix: Decimate models before loading. Use Level of Detail (LOD) to swap in simpler models at distance.
const lod = new THREE.LOD();
// High detail - close up
lod.addLevel(highDetailMesh, 0);
// Medium detail - mid range
lod.addLevel(mediumDetailMesh, 50);
// Low detail - far away
lod.addLevel(lowDetailMesh, 200);
scene.add(lod);
3. Texture Memory
Textures consume GPU memory. Exceed the GPU's VRAM and you'll see stuttering as textures swap in and out.
| Texture Size | Memory (RGBA) | Recommendation |
|---|---|---|
| 512 x 512 | 1 MB | Props, distant objects |
| 1024 x 1024 | 4 MB | Standard for web |
| 2048 x 2048 | 16 MB | Hero objects only |
| 4096 x 4096 | 64 MB | Avoid for web |
Fix: Resize textures to appropriate sizes. Use compressed texture formats (KTX2 with Basis Universal). Dispose textures when no longer needed.
// Use KTX2 loader for compressed textures
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
const ktx2Loader = new KTX2Loader()
.setTranscoderPath('/basis/')
.detectSupport(renderer);
ktx2Loader.load('texture.ktx2', (texture) => {
material.map = texture;
});
4. Shader Complexity
Complex materials with many texture lookups, lighting calculations, or custom shaders can bottleneck the GPU.
Fix: Use simpler materials for distant objects. Bake lighting into textures. Reduce light count (each light multiplies fragment shader work).
// Instead of MeshStandardMaterial (PBR, expensive)
const expensiveMaterial = new THREE.MeshStandardMaterial({
map: diffuse,
normalMap: normal,
roughnessMap: roughness,
metalnessMap: metalness,
aoMap: ao
});
// Use MeshBasicMaterial for distant/simple objects
const cheapMaterial = new THREE.MeshBasicMaterial({
map: bakedTexture // baked lighting into diffuse
});
Optimization Techniques
Frustum Culling (Free Performance)
Three.js automatically skips rendering objects outside the camera's view. Make sure it's enabled (it is by default):
mesh.frustumCulled = true; // default
For this to work efficiently, set proper bounding boxes:
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
Instancing for Repeated Objects
If you have many copies of the same geometry (trees, particles, buildings), use instancing:
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial();
// 1000 boxes, 1 draw call
const instancedMesh = new THREE.InstancedMesh(geometry, material, 1000);
const dummy = new THREE.Object3D();
for (let i = 0; i < 1000; i++) {
dummy.position.set(
Math.random() * 100,
Math.random() * 100,
Math.random() * 100
);
dummy.updateMatrix();
instancedMesh.setMatrixAt(i, dummy.matrix);
}
scene.add(instancedMesh);
This renders 1000 boxes in a single draw call.
Draco Compression for Faster Loading
Draco compresses geometry data by 80-90%, dramatically reducing download time:
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/');
const gltfLoader = new GLTFLoader();
gltfLoader.setDRACOLoader(dracoLoader);
gltfLoader.load('model.glb', (gltf) => {
scene.add(gltf.scene);
});
Dispose Resources Properly
Memory leaks kill performance over time. Always dispose when removing objects:
function disposeObject(obj) {
if (obj.geometry) {
obj.geometry.dispose();
}
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach(mat => disposeMaterial(mat));
} else {
disposeMaterial(obj.material);
}
}
}
function disposeMaterial(material) {
Object.keys(material).forEach(key => {
if (material[key] && material[key].isTexture) {
material[key].dispose();
}
});
material.dispose();
}
Model Optimization: Manual vs Automated
The techniques above help at runtime, but the biggest gains come from optimizing models before they reach Three.js.
Manual Approach (Blender)
- Import model
- Apply Decimate modifier to reduce polygons
- Resize textures in image editor
- Export with Draco compression
- Test in scene
- Repeat until performance targets met
Time: 30-60 minutes per model.
Automated Approach (API)
curl -X POST https://webdeliveryengine.com/optimize \
-H "Authorization: Bearer sk_your_key" \
-F "[email protected]" \
-F "mode=decimate" \
-F "ratio=0.5" \
--output model-optimized.glb
Time: Seconds to minutes per model (varies by file size). Includes polygon reduction, texture optimization, and Draco compression.
Integration with Build Pipeline
For projects with many models, integrate optimization into your build:
// optimize-models.js
const fs = require('fs');
const path = require('path');
const FormData = require('form-data');
async function optimizeModel(inputPath, outputPath) {
const form = new FormData();
form.append('file', fs.createReadStream(inputPath));
form.append('mode', 'decimate');
form.append('quality', '50');
const response = await fetch('https://webdeliveryengine.com/optimize', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MESHOPT_API_KEY}`
},
body: form
});
const buffer = await response.arrayBuffer();
fs.writeFileSync(outputPath, Buffer.from(buffer));
}
// Process all models in directory
const modelsDir = './src/models';
const outputDir = './public/models';
fs.readdirSync(modelsDir)
.filter(f => f.endsWith('.glb'))
.forEach(file => {
optimizeModel(
path.join(modelsDir, file),
path.join(outputDir, file)
);
});
Performance Checklist
Before deploying, verify:
- Draw calls under 100 - Check
renderer.info.render.calls - Total triangles under 500K - Use our GLB Inspector
- No texture over 2048px - Check Texture Calculator
- 60fps on target devices - Test on real hardware, not just your dev machine
- Under 3 seconds load time - Compress, use Draco, lazy load
- No memory leaks - Monitor MB counter in stats.js over time
Summary
Three.js performance problems almost always trace back to:
- Too many draw calls (merge meshes, use instancing)
- Too many polygons (decimate models)
- Textures too large (resize, compress)
- Shaders too complex (simplify materials)
Fix the models before they reach your scene, and runtime optimization becomes much easier. A 50MB unoptimized GLB will never hit 60fps no matter how clever your code is.
The best Three.js performance optimization happens before scene.add().