We built a 3D product viewer using Three.js. It ran at a consistent 60 frames per second on our development machines. On mid-range Android devices, the frame rate was unusable. The problem was not a single mistake, but a series of small, expensive decisions that compounded. This is the checklist we now use to keep our scenes fast on a wider range of hardware.

Count Your Draw Calls

A draw call is a command from the CPU to the GPU to render something. Each one carries overhead. A scene with thousands of objects can generate thousands of draw calls, and the CPU overhead of sending them can become the bottleneck, leaving the GPU waiting. Our target for a simple mobile scene is under a few hundred draw calls.

We had initially populated a scene with many small, identical objects by creating a new Mesh for each one inside a loop. This is simple to write but scales poorly.

// Fails at scale: one draw call per object
const geometry = new THREE.BoxGeometry(0.1, 0.1, 0.1);
const material = new THREE.MeshStandardMaterial({ color: 0xeeeeee });

for (let i = 0; i < 5000; i++) {
  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.set(
    (Math.random() - 0.5) * 10,
    (Math.random() - 0.5) * 10,
    (Math.random() - 0.5) * 10
  );
  scene.add(mesh);
}

This code generates 5000 draw calls. The GPU could render all this geometry instantly, but the CPU cannot issue the commands fast enough.

The fix is to use InstancedMesh. It allows the GPU to render many instances of the same geometry and material in a single draw call, with different positions, rotations, and scales.

// Better: one draw call for all objects
const count = 5000;
const geometry = new THREE.BoxGeometry(0.1, 0.1, 0.1);
const material = new THREE.MeshStandardMaterial({ color: 0xeeeeee });
const instancedMesh = new THREE.InstancedMesh(geometry, material, count);

const matrix = new THREE.Matrix4();
for (let i = 0; i < count; i++) {
  matrix.setPosition(
    (Math.random() - 0.5) * 10,
    (Math.random() - 0.5) * 10,
    (Math.random() - 0.5) * 10
  );
  instancedMesh.setMatrixAt(i, matrix);
}
instancedMesh.instanceMatrix.needsUpdate = true;
scene.add(instancedMesh);

This reduces 5000 draw calls to one. The tradeoff is that all instances must share the same geometry and material. For static objects that do not share a material, BufferGeometryUtils.mergeGeometries is an alternative that combines multiple geometries into one, also reducing draw calls. This increases memory usage, as it creates a single, larger geometry buffer.

Scrutinize Your Geometry

High polygon counts are an obvious source of performance issues. A 2-million-polygon model imported from a CAD program will not perform well on the web. We inspect all 3D assets before they enter our codebase. Tools like gltf-viewer or Blender's statistics overlay can show you the vertex and triangle count of a model.

Our rule is to use the lowest polygon count that preserves the required silhouette and detail at the camera distances we will actually use. We use Blender's Decimate modifier or automated tools like gltf-transform to reduce polygon counts from artist-provided models.

Beyond polygon count, the size of vertex attributes matters. A vertex is not just a position. It can include normals (for lighting), UVs (for textures), and vertex colors. A complex model can have a large memory footprint on the GPU even with a moderate polygon count if it has many vertex attributes. When we do not need them, we remove them.

Budget Your Materials and Textures

Not all materials are created equal. MeshStandardMaterial and MeshPhysicalMaterial are physically-based and react to lights in the scene. They produce beautiful results but are computationally expensive. Every light that can affect an object with these materials adds to the complexity of the fragment shader.

If an object does not need to be affected by lighting, use a cheaper material like MeshBasicMaterial. For our initial viewer, we used MeshStandardMaterial for everything. We gained back significant performance on mobile by switching non-critical background elements to MeshBasicMaterial.

Texture size is another major factor. A 4K texture (4096x4096 pixels) uses 16 times more VRAM than a 1K texture (1024x1024). This memory usage can be a hard limit on older mobile devices. We found that 2K textures were a good balance for hero assets, with 1K or smaller used for everything else. High-resolution textures also increase download times.

For textures, we now compress them into a modern, GPU-friendly format. Basis Universal, stored in a .ktx2 container, is our standard. It stays compressed in VRAM, significantly reducing memory usage compared to a .png or .jpg that must be decompressed into a raw bitmap. The tradeoff is a required preprocessing step for all texture assets and including the KTX2 loader in the JavaScript bundle.

Clean Up After Yourself

Three.js does not automatically manage GPU memory. When you call scene.remove(object), the JavaScript garbage collector can free the memory for the object itself. However, the underlying geometry, material, and textures that you uploaded to the GPU remain. This is a common source of memory leaks in long-running Three.js applications.

A memory leak in WebGL can cause the browser to terminate the rendering context, leaving the user with a blank canvas.

This is how an object is often removed, and it is incorrect.

// Incorrect: Leaks GPU memory
function removeObject(object) {
  scene.remove(object);
  // `object` is now eligible for JS garbage collection,
  // but its geometry and material data remain on the GPU.
}

To properly release memory, you must explicitly call the .dispose() method on the geometry and material(s), including any textures used by the material.

// Correct: Frees GPU memory
function disposeOfObject(object) {
  if (object.geometry) {
    object.geometry.dispose();
  }

  if (object.material) {
    if (Array.isArray(object.material)) {
      object.material.forEach(material => {
        // Dispose of all textures in the material
        for (const key of Object.keys(material)) {
          const value = material[key];
          if (value && typeof value === 'object' && value.isTexture) {
            value.dispose();
          }
        }
        material.dispose();
      });
    } else {
      // Dispose of all textures in the material
      for (const key of Object.keys(object.material)) {
        const value = object.material[key];
        if (value && typeof value === 'object' && value.isTexture) {
          value.dispose();
        }
      }
      object.material.dispose();
    }
  }

  scene.remove(object);
}

This process is manual and error-prone. We write helper functions to traverse an object and its children, disposing of all resources. The need for careful manual memory management is a significant tradeoff for the performance and control Three.js provides.