React Three Fiber Without Jank: Measuring and Optimizing WebGL Scenes

A practical guide to measuring the frame budget and optimizing draw calls, React work, DPR, textures, shadows, shaders, and memory in WebGL scenes.

10 min

React Three Fiber performance: frame budget, draw calls and GPU

A smooth 3D scene does not come from stacking tricks. It comes from managing a budget. Every frame must finish its JavaScript work, scene updates, and GPU drawing before the next display refresh arrives.

On a 60 Hz display, that window is roughly 16.7 ms; at 120 Hz it falls to 8.3 ms. These are not universal targets: the device, visual complexity, and type of interaction determine the real budget.

Optimizing React Three Fiber means finding which part of the frame consumes the budget and lowering that cost without losing the visual intent.

01. Start with the frame budget

Anatomy of a WebGL frame budget across CPU work, rendering, and the GPUAnatomy of a WebGL frame budget across CPU work, rendering, and the GPU

Low FPS describes a symptom, not a cause. The bottleneck can live in different places:

SignalLikely causeFirst check
JavaScript takes too longcalculations, allocations, or React rendersDevTools Performance
too many draw callstoo many separate objects or materialsgl.info.render.calls
too many trianglesdenser geometry than the view needsgl.info.render.triangles
the GPU stalls at higher DPRfill rate, shadows, or post-processingcompare DPR and resolution
memory grows after navigationtextures, materials, or geometry are retainedgl.info.memory

React Three Fiber exposes the Three.js renderer through useThree. During development, you can sample its counters without updating React state every frame:

tsx
function RendererProbe() {
  const gl = useThree((state) => state.gl);
  const lastReport = useRef(0);

  useFrame(() => {
    const now = performance.now();
    if (now - lastReport.current < 1000) return;
    lastReport.current = now;

    console.table({
      calls: gl.info.render.calls,
      triangles: gl.info.render.triangles,
      geometries: gl.info.memory.geometries,
      textures: gl.info.memory.textures,
    });
  });

  return null;
}

Treat this as a temporary probe, not production telemetry. Combine these numbers with a browser profile and test on a representative device; the development laptop is rarely the real limit.

02. Reduce draw calls before reducing detail

Visual comparison between hundreds of individual meshes and one InstancedMeshVisual comparison between hundreds of individual meshes and one InstancedMesh

The GPU can process many vertices, but each draw call requires coordination between the CPU and GPU. Hundreds of objects that share one geometry and material are strong candidates for InstancedMesh.

tsx
function Field({ count = 1000 }) {
  const mesh = useRef<THREE.InstancedMesh>(null);
  const transform = useMemo(() => new THREE.Object3D(), []);

  useLayoutEffect(() => {
    if (!mesh.current) return;

    for (let index = 0; index < count; index += 1) {
      transform.position.set(
        (index % 40) - 20,
        0,
        Math.floor(index / 40) - 12,
      );
      transform.updateMatrix();
      mesh.current.setMatrixAt(index, transform.matrix);
    }

    mesh.current.instanceMatrix.needsUpdate = true;
    mesh.current.computeBoundingSphere();
  }, [count, transform]);

  return (
    <instancedMesh ref={mesh} args={[undefined, undefined, count]}>
      <boxGeometry args={[0.18, 0.18, 0.18]} />
      <meshStandardMaterial color="#6d4aff" />
    </instancedMesh>
  );
}

Instancing works when instances share geometry and material. For different static objects, consider merging compatible geometries. Share materials and geometries instead of recreating them inside every component.

Do not optimize by object count alone: one mesh with an expensive shader or millions of triangles can still saturate the GPU.

03. Keep per-frame work outside React

useFrame runs inside the render loop. Calling setState there can trigger reconciliation at display speed. For high-frequency animation, mutate Three.js references and reserve React state for semantic UI changes.

tsx
function Rotor({ speed = 0.8 }) {
  const group = useRef<THREE.Group>(null);

  useFrame((_, delta) => {
    if (group.current) {
      group.current.rotation.y += speed * delta;
    }
  });

  return <group ref={group}>{/* scene content */}</group>;
}

Using delta keeps motion independent of the frame rate. Also avoid creating vectors, colors, or arrays inside the loop; reuse objects or calculate stable data with useMemo. A tiny allocation repeated thousands of times eventually becomes a garbage-collection pause.

04. Do not render frames nobody can see

An animated scene needs frameloop="always", the usual default. A configurator, product model, or data view that only changes on interaction can render on demand:

tsx
function ProductViewer() {
  return (
    <Canvas frameloop="demand" dpr={[1, 1.5]}>
      <Scene />
    </Canvas>
  );
}

function MaterialSync({ color }: { color: string }) {
  const material = useRef<THREE.MeshStandardMaterial>(null);
  const invalidate = useThree((state) => state.invalidate);

  useEffect(() => {
    material.current?.color.set(color);
    invalidate();
  }, [color, invalidate]);

  return <meshStandardMaterial ref={material} />;
}

Declarative changes managed by React Three Fiber request frames when needed. If you mutate an object imperatively, call invalidate() to schedule the next one. Do not combine on-demand rendering with continuous animation unless you define what wakes the loop.

05. Control the cost of every pixel

Technical WebGL quality panel with DPR, shadows, textures, and post-processing controlsTechnical WebGL quality panel with DPR, shadows, textures, and post-processing controls

Doubling DPR can approach four times as many pixels. That is why a scene that runs smoothly on a standard display can drop frames on a high-density display even with the same draw calls.

Order quality decisions by impact:

  1. cap DPR at a reasonable range;
  2. reduce the resolution and number of shadow maps;
  3. limit shadow-casting lights and shadow receivers;
  4. compress and size textures for their visible use;
  5. remove post-processing passes that do not justify another full render;
  6. use levels of detail for distant objects.

Textures often dominate memory and bandwidth. A large image does not become cheap because its network file is small: on the GPU, it expands into a representation suitable for sampling. Match dimensions, format, and mipmaps to the real use case.

06. Shaders: moving work does not remove its cost

A shader can replace thousands of JavaScript updates with parallel GPU work. It is a strong fit for waves, particles, and deformation, but it is not permission to do unlimited work per vertex or fragment.

glsl
uniform float uTime;
attribute float phase;

void main() {
  vec3 displaced = position;
  displaced.y += sin(uTime + phase) * 0.08;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
}

Update existing uniforms instead of rebuilding materials. Avoid multiplying shader variants through changing defines: every combination can require another program and compilation. Profile vertex-bound and fill-rate-bound scenes separately.

07. Release resources and optimize in order

Editorial sequence for diagnosing and optimizing a React Three Fiber sceneEditorial sequence for diagnosing and optimizing a React Three Fiber scene

Three.js cannot automatically release every GPU resource when a JavaScript reference disappears. Objects created manually outside the reconciler—or retained in custom caches—need explicit ownership and dispose() calls for geometries, materials, textures, and render targets.

Before calling an optimization complete, follow this order:

  • define a device, scene, and measurable target;
  • identify whether the limit is CPU, draw calls, geometry, pixels, or memory;
  • reduce structural work: React renders, objects, materials, and draw calls;
  • tune DPR, shadows, textures, and post-processing;
  • remove allocations from the loop and stabilize resources;
  • optimize shaders only when the profile points to the GPU;
  • measure again and compare visual quality.

The best result is not the highest FPS in an empty scene. It is a consistent experience that preserves its visual hierarchy within the budget of the devices that actually run it.


SESSION_ELAPSED00:00:00
LOCALE: ENENV: PROD