Performance is the constraint that shapes every Three.js architecture decision. The target is 60fps (16.67ms per frame) on the lowest-spec device you support. Miss that target and users experience lag between input and response. The visualisation stops feeling interactive and starts feeling broken.
Three.js performance problems fall into three categories: too many draw calls (GPU-bound), too much JavaScript per frame (CPU-bound), and too much memory (leak-bound). Each has different symptoms, different profiling methods, and different fixes.
Frame Budgets
A frame at 60fps has 16.67 milliseconds. In that window, the browser must run JavaScript (scene updates, raycasting, data processing), submit draw calls to the GPU, composite the result, and handle any pending events.
The reality: On integrated GPUs (most laptops, all mobile devices), the GPU is slower and the thermal budget is tighter. After 5-10 minutes of sustained rendering, mobile devices throttle their GPU and CPU. Performance that seemed acceptable during a quick test degrades under continuous use. Testing must happen on real devices under sustained load, not brief demos on development machines.
This is why performance is an architecture concern, not a polish step. Decisions made in the first week of a project determine whether the application hits 60fps or 15fps six months later.
Death by a Thousand Cuts
Performance problems accumulate gradually. Each individually seems acceptable. Together they compound into an unusable application.
These patterns persist because they work in demos with simple scenes and short sessions. Production code with real data volumes and real user sessions exposes every one.
Draw Call Reduction
Draw calls are the single most important performance metric for most Three.js applications. Each unique combination of geometry and material requires a draw call to the GPU. Reducing draw calls is almost always the highest-impact optimisation.
Instanced Rendering
InstancedMesh renders N copies of the same geometry with one draw call. Each instance has its own position, rotation, scale, and colour stored in per-instance buffers.
10,000 individual Meshes = 10,000 draw calls. One InstancedMesh with 10,000 instances = 1 draw call.
Geometry Merging
For static scenes where objects share a material, merge their geometries into a single BufferGeometry. One draw call for the merged geometry instead of one per original object.
Trade-off: merged geometry cannot be individually hidden, moved, or removed.
Material Reuse
Create material instances once and share them across objects. Five hundred cubes with the same colour should reference the same MeshStandardMaterial, not five hundred copies.
Fewer unique materials means more objects drawn together in batch.
Impact: Instanced rendering alone can improve frame rates by 10x or more for large datasets on integrated GPUs. A 10,000-point scatter plot using one InstancedMesh rather than 10,000 Meshes is the difference between 15fps and 60fps.
The trade-off with instancing: all instances share the same geometry and material. If you need 3 different shapes, you need 3 InstancedMesh objects (3 draw calls total, still vastly better than thousands). For varied shapes, custom shaders can select geometry per instance.
Memory Management
GPU resources are not garbage collected. Unlike JavaScript objects, Three.js geometries, materials, and textures occupy GPU memory that must be explicitly freed. Failing to call .dispose() creates memory leaks that accumulate over the user's session.
| Resource | Naive approach | Robust pattern |
|---|---|---|
| Geometry | Remove from scene only | Call geometry.dispose() then remove |
| Material | Let garbage collector handle it | Call material.dispose() for each material |
| Texture | Assume browser cleans up | Call texture.dispose() before dereferencing |
| Render targets | Never disposed | Call renderTarget.dispose() when done |
The disposal sequence for every removed object: parent.remove(mesh), then mesh.geometry.dispose(), then material.dispose() for each material (including its .map, .normalMap, and other texture references), then null the references. Skip any step and the GPU memory stays allocated.
Monitor for leaks with renderer.info.memory.geometries and renderer.info.memory.textures during development. If either number grows over time without a corresponding increase in visible scene complexity, something is not being disposed. Chrome's Task Manager (Shift+Esc) shows GPU memory per tab for catching the slow leaks that manifest only after extended use.
Mobile Optimisation
Mobile devices have two constraints desktop developers underestimate: GPU power and thermal throttling. A scene that runs at 60fps on a development MacBook runs at 30fps on a two-year-old Android phone, and drops to 20fps after five minutes as the device throttles.
Adaptive Quality
Detect device capability and adjust. Cap pixel ratio at 2 (modern phones render at 3x, meaning 9x more pixels). Disable shadow mapping. Remove post-processing passes (bloom, SSAO).
One line of code (capping pixel ratio) can double mobile frame rate.
Throttling Detection
Monitor frame time over a rolling window. If average frame time exceeds 20ms for 30 consecutive frames, reduce quality: lower pixel ratio, simplify materials, reduce draw distance.
Adaptive rendering maintains interactivity on throttled devices.
Touch Interaction
There is no hover state on touch. Pinch-zoom conflicts with page zoom. Single-finger drag could mean orbit or page scroll. Capture touch events and provide explicit mode toggles.
Test on real devices under sustained use, not brief demos.
The principle: detect capabilities rather than screen size. A desktop with integrated graphics needs the same optimisations as mobile. A high-end iPad can handle more than a budget Android phone. Profile, measure, adapt.
Profiling and Debugging
Performance optimisation without measurement is guesswork. Three tools cover most situations: renderer.info.render.calls for draw call counts (the single most important metric), the Chrome DevTools Performance panel for frame timing and GC spikes, and renderer.info.memory for leak detection. For GPU-bound scenes, Chrome's GPU panel or RenderDoc reveals per-draw-call timings and overdraw.
Start with draw calls. If high, instancing and material reuse are the fix. If JavaScript time dominates, throttle updates and offload to Web Workers. If memory grows over time, find the disposal gap.
Quick Wins
Five changes that typically have the biggest frame rate impact, ranked by implementation effort. Start here before profiling anything more exotic.
Share materials across objects
Objects with the same appearance should reference the same material instance. Minutes to implement, often doubles FPS. No visual change. The single easiest performance win.
Use InstancedMesh for repeated geometry
An hour to refactor, 10x improvement for large object counts. Replace loops that create individual Meshes with a single InstancedMesh and per-instance transforms.
Cap pixel ratio at 2
One line of code: renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)). Significant improvement on high-DPI mobile screens. Barely perceptible quality difference.
Throttle raycasting to 30Hz
Run raycasting on a timer instead of every mouse move event. One timer, noticeable improvement in mousemove-heavy interactions. Users do not perceive the 33ms delay.
Implement disposal in cleanup functions
Prevents the slow memory leak that crashes tabs after extended use. Traverse the scene on teardown and dispose every geometry, material, and texture.
Before and After
A typical Three.js scene with 10,000 objects before and after applying the patterns above. The numbers are representative of real-world production applications.
| Metric | Before (naive) | After (optimised) |
|---|---|---|
| Draw calls | 10,000 per frame | 3 per frame (instanced) |
| Frame rate | 12-15 fps on laptop | 60 fps on laptop, 45+ on mobile |
| Memory after 1 hour | 1.2 GB (leaking) | 180 MB (stable) |
| Initial load | 4.2 seconds | 0.8 seconds |
| Raycast hover | 8ms per frame (every frame) | 0.1ms per frame (GPU picking) |
The visual output is identical. The user experience is entirely different. One is a tool people use; the other is a tool people abandon.
The Business Link
Performance is not a technical nicety. It is the difference between a tool people use and a tool people abandon. Configurators that lag have lower completion rates. Dashboards that stutter discourage exploration. Applications that crash on mobile generate support tickets. The effort spent on instancing, disposal, and adaptive quality pays back for the entire lifetime of the application, and it is cheaper to build correctly than to fix later.
Build 3D That Performs
We build Three.js interfaces optimised for real data volumes on real devices. 60fps on target hardware, proper memory management, adaptive quality for mobile. Performance built in from the architecture, not patched in after launch.
Let's talk about your 3D project →