The first time I shipped a real Three.js scene to the web, it ran beautifully on my machine and turned into a slideshow on a mid-range phone. That is the rite of passage. Three.js performance on the web is a different game from native, because you are sharing a single main thread with the whole page and running on hardware that ranges from gaming desktops to three-year-old budget phones. Most of the lessons came from watching the frame rate collapse and figuring out why.
Draw calls are usually the real enemy
The single most common cause of a slow Three.js scene is too many draw calls. Every distinct mesh with its own geometry and material is roughly one draw call, and the cost is the overhead of telling the GPU to start drawing, not the triangles themselves. A scene with five hundred separate small objects can be slower than one with a handful of large ones, even when the second has more geometry.
The fixes are about reducing the count. Merge static geometry that shares a material into a single mesh. Use instanced meshes when you have many copies of the same object, like trees or particles or tiles, because instancing draws all of them in one call. Share materials aggressively, since a new material often forces a new draw call even on identical geometry. I keep an eye on the renderer info panel during development so I notice the count creeping up before it becomes a problem.
- Merge static meshes that share a material into one geometry
- Use InstancedMesh for large numbers of repeated objects
- Reuse a small set of materials instead of one per object
- Watch renderer.info.render.calls while you build, not after
The main thread is shared, and that hurts
On the web your render loop is competing with React, with layout, with garbage collection, with everything else the page is doing. A frame budget at 60fps is about sixteen milliseconds, and that has to cover your scene update, the render, and whatever else the browser needs to do. One heavy synchronous operation in the middle of a frame and you drop it.
This means the usual web rules apply harder than usual. Keep per-frame work lean and predictable. Do not allocate objects inside your animation loop, because the garbage collector will eventually pause you at the worst moment. Move heavy computation like physics or pathfinding off the main thread with a worker when you can. And be careful with framework re-renders, since a React component re-rendering your canvas wrapper every frame can quietly double your cost.
Memory and textures will sink you on mobile
Desktop hides a lot of sins that mobile will not forgive. Textures are the big one. A few uncompressed high-resolution textures can blow past a phone's memory budget and either crash the tab or force the browser to thrash. I size textures to what they actually need on screen, use power-of-two dimensions, generate mipmaps, and reach for compressed texture formats like KTX2 with Basis when the project can support them.
The other memory trap is that Three.js does not clean up after you automatically. Geometries, materials, and textures hold GPU resources that you have to dispose of explicitly when you are done with them. In a long-lived single page app where scenes come and go, forgetting to dispose is a slow leak that ends in a crash after a few navigations. I treat teardown as a first-class part of any scene, not an afterthought.
Measure on real hardware, not your laptop
The mistake I made early and see constantly is profiling only on a development machine. Your laptop has a real GPU and plenty of memory, so everything feels fine. Your users are on phones with thermal throttling and a fraction of the power. A scene that holds 60fps on your desk can sit at 20fps on the device most of your audience actually has.
I keep a couple of genuinely mid-range devices around and test on them regularly, not just at the end. The browser performance tools and the WebGL frame inspectors are useful, but nothing replaces holding a real phone and feeling the lag. Budget for the worst common device you intend to support and tune to that, because the powerful machines will take care of themselves.
Level of detail and culling are free wins
Two techniques pay off constantly once a scene gets large. Level of detail swaps a complex mesh for a simpler one as it gets farther from the camera, so distant objects cost almost nothing. Frustum culling, which Three.js does by default, skips anything outside the camera view, but it only helps if your objects are organized so the culling can actually exclude them. For big worlds, spatial partitioning so you are not even considering off-screen regions matters more than any micro-optimization.
None of this is exotic. The pattern that works is boring and reliable. Keep draw calls low, keep the main thread clear, respect the memory budget of the weakest device, and measure where it actually runs. Do those four things and most Three.js performance problems never get a chance to appear. Skip them and you will rediscover each one the hard way, the same way I did.
Building something where this matters?
I am open to senior full-stack, Web3, or AI engineering roles, fully remote and any timezone. If the hard part of your product is fighting you, that is the work I like.
Get in touch →