60fps on Real Devices with Real Data
Three.js performance optimisation is the constraint that shapes every architecture decision. The target is 60fps (16.67ms per frame) on the lowest-spec device you support. Miss it and users feel the 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 its own symptoms, its own profiling method, and its own fix. Get the diagnosis right and the fix is usually quick; guess at it and you optimise the wrong thing for a week.
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. They split into three families, and naming the family tells you the fix.
Draw-call problems submit too much work to the GPU each frame:
Allocation problems churn the garbage collector and stutter the main thread:
Memory problems leave GPU resources allocated until the tab runs out:
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.
BatchedMesh (r156+)
BatchedMesh batches different geometries that share a material into one draw call, while keeping each one individually addressable. You can still show, hide, and move them.
Use it when objects differ in shape but share a material, the case InstancedMesh cannot handle.
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.
The decision is straightforward once you know the constraint. Same geometry across all objects: InstancedMesh. Different geometries but one shared material, with individual control still needed: BatchedMesh. Truly static objects that never move or hide: geometry merging. When in doubt, reach for instancing first, since it covers the most common case (thousands of identical markers, particles, or repeated props).
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 that share a material, BatchedMesh is usually the cleaner answer.
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.
The hard part is not reading the numbers, it is matching a symptom to a cause. Most wasted optimisation effort comes from fixing the wrong bottleneck. Use the signature, not a hunch.
| What you see | Likely bottleneck | Where to look |
|---|---|---|
| High draw calls, low frame rate | GPU-bound on submission overhead | Instancing, BatchedMesh, material reuse |
| Low draw calls, still slow | GPU-bound on fill rate or shaders | Simpler materials, fewer post-processing passes, lower pixel ratio |
| Long JavaScript blocks per frame | CPU-bound on update logic | Throttle raycasting, pre-allocate objects, offload to a Web Worker |
| Regular GC spikes and stutter | CPU-bound on allocation churn | Reuse vectors and matrices, stop allocating in the render loop |
| Memory counters climbing over time | Leak-bound on missing disposal | Audit the disposal sequence, check renderer.info.memory |
| Fine at first, degrades after minutes | Thermal throttling on mobile | Adaptive quality, rolling frame-time monitor |
Start with draw calls every time. They are the cheapest thing to read and the most common cause. Once that number is low and the frame rate is still poor, you know to look at fill rate, JavaScript time, or memory instead.
One renderer choice sits underneath all of this. The Three.js WebGPU renderer (production-ready since r171) helps most in draw-call-heavy and compute-heavy scenes, where it can deliver 2-10x gains, with automatic WebGL 2 fallback so adoption carries little risk. It is not universally faster, though: some mobile cases still favour WebGL 2, so profile your own scene before committing rather than assuming the newer pipeline wins.
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 matrices.
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. Data visualisations 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 →