Three.js Performance and Optimisation

60fps on Real Devices with Real Data


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.

WebGL Performance Lab
Object Count
1,000
Technique
Complexity
Lighting
Renderer: WebGL2
Geom: TorusKnot
60
Frames / Sec
1
Draw Calls
1024.0k
Triangles
Frame Time (CPU + GPU) 16.7 ms


Turn Instancing OFF and Animate ON with high object counts to visualize the CPU bottleneck caused by thousands of individual matrix updates and draw calls.

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.

One Mesh per data point. 10,000 data points = 10,000 draw calls. Frame rate drops below 20fps on integrated GPUs.
New geometry allocated every frame. Garbage collection pauses cause visible stuttering every few seconds.
Materials created per object. 1,000 objects with identical appearance use 1,000 material instances instead of one.
Textures at source resolution. A 4096×4096 texture for a thumbnail-sized element consumes 64MB of GPU memory.
No disposal of removed objects. GPU memory grows linearly with session length. After an hour, the tab crashes.
Raycasting against all objects on every mouse move. At 10,000 objects, each raycast takes 5-10ms, eating a third of the frame budget.

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.

1

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.

2

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.

3

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.

4

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.

5

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 →
Graphic Swish