Optimizing Three.js for the Web
Optimization without profiling is guesswork. The techniques in this guide are ordered by typical impact, but every game is different. A game bottlenecked by draw calls will not benefit from texture compression. A game bottlenecked by JavaScript garbage collection will not improve from LOD. Always measure first, then apply the optimization that addresses the actual bottleneck.
Step 1: Profile Before Optimizing
Three.js exposes rendering statistics through renderer.info. After each render call, this object reports the number of draw calls (render.calls), triangles (render.triangles), points, lines, the number of textures in memory (memory.textures), and geometries (memory.geometries). Display these numbers on screen during development using a stats overlay like stats.js or r3f-perf.
Chrome DevTools Performance panel records frame timings. Record a few seconds of gameplay and examine the flame chart. Look for long frames (spikes above the 16.6ms line for 60fps) and identify what caused them: JavaScript execution (yellow), rendering/GPU work (green), or garbage collection (purple GC events). The Rendering tab in DevTools also offers FPS monitoring and paint flashing.
Spector.js is a browser extension that captures a single frame's WebGL commands. It shows every draw call, the state (shader, textures, blend mode) at each call, and the framebuffer output. This reveals exactly which objects are expensive to draw and whether the GPU is doing redundant work. Use it when you suspect specific objects or materials are causing bottlenecks but renderer.info does not provide enough detail.
For JavaScript profiling, Chrome's Performance panel shows function-level timing. Look for hot functions in the game loop that consume disproportionate time. Three.js's internal functions like projectObject, setProgram, and renderBufferDirect appear in the profile when draw call submission is the bottleneck.
Step 2: Reduce Draw Calls
Draw call count is the most common bottleneck in Three.js games. Each unique combination of geometry and material produces a draw call, and the CPU cost of submitting draw calls to the GPU dominates frame time in scenes with many objects. WebGL has higher per-draw-call overhead than native graphics APIs, making this especially important for browser games.
Instanced rendering draws many copies of the same geometry with different positions, rotations, and scales in a single draw call. Use InstancedMesh for any object that appears multiple times: trees, rocks, bullets, particles, coins, enemies of the same type. Create an InstancedMesh with the shared geometry, shared material, and the maximum instance count. Set each instance's transform with setMatrixAt(index, matrix). This can reduce hundreds of draw calls to one.
Geometry merging combines multiple meshes into a single BufferGeometry using BufferGeometryUtils.mergeGeometries. This is ideal for static scenery (buildings, terrain decorations, walls) that does not move or change. The merged geometry draws in one call regardless of how many original meshes it contains. The trade-off is that you lose individual object control: you cannot hide, move, or apply different materials to merged geometry without rebuilding it.
Material sharing reduces draw calls by ensuring objects that look the same use the same material instance, not just the same material configuration. Two meshes with separate MeshStandardMaterial instances that have identical properties still generate separate draw calls. Use a single material instance for all meshes that share the same appearance. Texture atlases combine multiple textures into one, allowing different-looking objects to share a material by using different UV coordinates within the atlas.
Step 3: Manage GPU Memory
GPU memory (VRAM) is limited, especially on mobile devices where it may be shared with system RAM. Textures are the largest consumers of GPU memory. A single 2048x2048 RGBA texture uses 16MB of VRAM uncompressed. A game with 20 such textures uses 320MB, which exceeds the available VRAM on many mobile GPUs and causes the browser to either crash or fall back to software rendering.
Use KTX2 with Basis Universal compression to reduce texture GPU memory by 4-6x. A 2048x2048 texture compressed with BC7 (desktop) or ASTC (mobile) uses 4MB instead of 16MB. Reduce texture resolution where quality is not noticeably affected: 512x512 textures are sufficient for small props, and many game objects look fine with 1024x1024 instead of 2048x2048.
Dispose resources when they are no longer needed. Three.js does not automatically free GPU memory when you remove an object from the scene. Call geometry.dispose(), material.dispose(), and texture.dispose() for every resource you unload. Track resource lifecycle carefully in level-based games where entire scenes are swapped. A common pattern is a resource manager class that tracks all allocated resources and provides a dispose-all method for level transitions.
Monitor GPU memory with renderer.info.memory, which reports the count (not the byte size) of geometries and textures currently in GPU memory. For byte-level tracking, estimate based on texture dimensions and format. Watch for leaks by logging the counts over time and checking that they return to expected values after level transitions or object cleanup.
Step 4: Implement Level of Detail
Level of Detail (LOD) reduces the polygon count and visual complexity of objects based on their distance from the camera. A tree model might use 5,000 polygons when nearby, 500 polygons at medium distance, and a flat 2D billboard at long range. The visual difference at distance is negligible, but the performance savings are substantial when multiplied across hundreds of objects.
Three.js provides the LOD class, which automatically switches between different meshes based on camera distance. Add multiple levels with lod.addLevel(mesh, distance), where distance is the threshold in world units at which that level activates. The LOD object replaces the object in the scene graph and handles level switching each frame during the render traversal.
For environments, generate simplified geometry with Blender's Decimate modifier or gltf-transform's simplify command. Target a 50-80% polygon reduction per LOD level. The visual quality of simplified models at distance is surprisingly good because fine details are not visible at that range anyway. Combine LOD with instancing for maximum effect: an InstancedMesh of simplified trees at distance is dramatically cheaper than individual high-poly tree meshes.
Frustum culling (skipping objects outside the camera's view) is built into Three.js and enabled by default. Objects with frustumCulled = true (the default) are tested against the camera frustum each frame, and those outside the view are not drawn. For large scenes, this automatically eliminates a significant portion of draw calls. Ensure bounding spheres are accurate (call geometry.computeBoundingSphere()) for correct culling.
Step 5: Optimize JavaScript Performance
JavaScript garbage collection (GC) pauses the main thread to reclaim unused memory, causing frame drops that appear as periodic stutters. The primary cause of GC pressure in game loops is object allocation: creating new Vector3, Quaternion, Matrix4, or plain objects every frame. Pre-allocate these as reusable variables outside the loop and modify them in place rather than creating new instances.
Object pooling reuses objects rather than creating and discarding them. Bullets, particles, enemies, and other frequently spawned entities should be drawn from a pool of pre-allocated objects. When a bullet is fired, activate an object from the pool and set its position. When it hits something or leaves the screen, deactivate it and return it to the pool. This eliminates the allocation and GC cost of constantly creating and destroying objects.
Avoid closures and arrow functions inside the game loop, as they create new function objects each frame. Define callback functions once outside the loop and reference them by name. Use typed arrays (Float32Array, Uint16Array) instead of regular arrays for large numerical datasets like particle systems, because typed arrays are more memory-efficient and avoid boxing numeric values.
Three.js's Raycaster allocates internal objects on each cast. If you raycast every frame (for mouse picking or ground detection), the allocations add GC pressure. Cache the raycaster instance and reuse it. For multiple raycasts per frame, batch them and process results together rather than interleaving raycasting with other logic.
Step 6: Target Mobile Browsers
Mobile GPUs have a fraction of the power of desktop GPUs, and they throttle performance when the device gets warm. A game that runs at 60fps on a laptop may run at 15fps on a phone after 30 seconds of gameplay as the GPU throttles to manage heat. Design for mobile from the start rather than optimizing later.
Reduce the rendering resolution with renderer.setPixelRatio. Most mobile screens have pixel ratios of 2-3x, meaning a 1080p logical viewport renders at 2160p or 3240p. Rendering at native resolution is wasteful because the small screen size makes per-pixel quality improvements invisible. Cap the pixel ratio at 1.5 or 2.0 for a significant performance boost with minimal visual impact.
Disable or reduce expensive features on mobile: use simpler shadow maps or disable shadows entirely, reduce post-processing passes, lower texture resolution, decrease draw distance, and reduce the number of active lights. Detect mobile devices using user agent or screen size and apply a preset quality configuration automatically. Better yet, provide a quality settings menu that lets players choose their own trade-off between visuals and performance.
Touch input has higher latency than mouse input, and mobile browsers add an additional ~100ms delay for touch events unless you use CSS touch-action: none on the canvas and handle pointer events rather than touch events. This optimization is critical for responsive game controls on mobile devices.
Profile first to identify actual bottlenecks, then apply targeted optimizations. Draw call reduction through instancing and merging is typically the highest-impact optimization. GPU memory management through texture compression prevents crashes on mobile. JavaScript object pooling eliminates GC stutter. Apply all three for a game that runs well across devices.