Use this guide when a Three.js or React Three Fiber scene already works but needs evidence-backed improvements. Treat performance, resource ownership, render correctness, and interaction behavior as separate questions. A source scan can suggest where to look; only a running scene can show whether a change helped.
Establish a repeatable baseline
Choose one route, viewport, device-pixel ratio, camera position, and interaction sequence. Capture these before changing code:
- a screenshot after the first stable frame
- frame time while idle and during the main interaction
renderer.info.rendercounts andrenderer.info.memorycounts- a browser performance trace for a slow or stuttering interaction
- console warnings and WebGL context errors
Record whether the measurement is from development or production. Development instrumentation and React Strict Mode can change timings and lifecycle counts, so compare like with like.
Trace ownership before disposal
Inventory resources by the code that creates and releases them:
| Resource | Common owner | Release check |
|---|---|---|
| Geometry and material | Scene component or asset cache | Removed objects no longer retain them; imperatively created resources call dispose() |
| Texture | Loader cache or owning feature | The owner releases textures that are not shared elsewhere |
| Render target | Postprocessing pass or custom renderer | Resize replaces and disposes the old target; unmount disposes the final target |
| Listener or observer | Controls, resize logic, pointer integration | Cleanup removes the same callback and target that setup registered |
| Animation loop | Renderer or <Canvas> | Only one loop owns frame scheduling, and teardown stops it |
Do not dispose a shared resource from one consumer while another consumer still uses it. If ownership is unclear, make it explicit before adding cleanup.
For a plain Three.js feature that owns resources directly, keep setup and teardown together:
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshStandardMaterial()
const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)
function destroyFeature() {
scene.remove(mesh)
geometry.dispose()
material.dispose()
}
Removing an object from the scene does not release its GPU allocations. Three.js cannot infer whether a texture, material, or geometry will be reused, so the owner must decide when disposal is safe.
Inspect work that repeats
Start with useFrame, requestAnimationFrame, pointer-move handlers, resize
handlers, and per-frame postprocessing callbacks. For each one, ask:
- Does it allocate objects or arrays?
- Does it trigger React state or rebuild scene objects?
- Does it traverse more of the scene than the interaction needs?
- Does it run while the scene is visually unchanged?
Reuse scratch values in hot paths:
const target = new THREE.Vector3()
function MovingMesh() {
const mesh = useRef<THREE.Mesh>(null)
useFrame(({ clock }, delta) => {
target.set(0, Math.sin(clock.elapsedTime), 0)
const alpha = 1 - Math.exp(-5 * delta)
mesh.current?.position.lerp(target, alpha)
})
return <mesh ref={mesh} />
}
Use React state for durable application state and discrete events. For values that change every frame, mutate the Three.js object through a ref and use the frame delta when movement should be independent of refresh rate.
Check whether every frame is necessary
An application that changes only after input can use React Three Fiber's demand rendering:
<Canvas frameloop="demand">
<Scene />
</Canvas>
R3F schedules a frame when React detects prop changes. If code mutates an object
outside React, call invalidate() to request a future frame. Do not call
invalidate() continuously; that recreates an always-running loop.
When an animation begins after an input event, call invalidate(), then start
the animation in the next requestAnimationFrame callback. That gives demand
rendering a frame before the animation changes its first value. Confirm controls
and animation helpers already invalidate before adding another listener.
Find scene churn and excess draw work
Watch renderer.info while repeating the same action. Counts that rise and
never settle point to retained resources or repeated construction. Large stable
counts can still indicate excess draw work.
- Reuse geometry and materials when objects have the same shape and surface.
- Instance repeated meshes when separate objects would produce many draw calls.
- Keep constructor arguments stable; changing R3F
argsreconstructs the underlying object. - Load assets through the framework cache rather than starting a new load from each component.
- Limit raycasts to interactive targets instead of the whole scene.
- Size shadow maps, postprocessing buffers, and render targets to the quality the experience actually needs.
Optimize from measurements. A lower draw-call count is useful only if the frame trace or target hardware shows that draw submission is the constraint.
Verify renderer and viewport behavior
Exercise the scene at each supported viewport and device-pixel ratio. In plain Three.js, update the renderer size and camera projection when the canvas display size changes. Avoid resizing the drawing buffer on every frame when its size is already correct.
Check color textures and output color space separately from data textures. Normal, roughness, metalness, and similar data maps should not receive color space conversion. Confirm tone mapping, exposure, lights, and environment maps with a stable reference frame before changing material values to compensate.
Review the rendered result
Capture evidence after initial load, after moving the camera, after the primary interaction, and after resizing. Check:
- the intended subject remains framed and controls do not clip through it
- opaque, transparent, and transmissive surfaces sort as intended
- seams, coplanar surfaces, and shadows remain stable while the camera moves
- textures keep their orientation and detail at near and grazing views
- loading, empty, error, and reduced-motion states remain usable
- keyboard and pointer actions produce the same state changes the UI promises
- essential information and state in the canvas also have a DOM or text equivalent that assistive technology can read
- a WebGL initialization failure or context loss produces a usable fallback instead of an unexplained blank region
Do not report a visual failure from source alone. If the scene cannot be run, label the item as a source-based risk and name the missing runtime evidence.
Fix one measured cause at a time
Start with crashes, context loss, unbounded resource growth, and hot-path work that appears in the performance trace. Then address interaction and visual failures. Keep aesthetic refinements separate from performance changes so the evidence still identifies what moved the result.
After each fix:
- Repeat the exact baseline interaction.
- Compare frame timing and
renderer.infounder the same conditions. - Revisit every viewport and visual state affected by the change.
- Mount, interact, unmount, and remount to exercise cleanup.
- Confirm the console has no new warnings or WebGL errors.
A completed audit links each change to a reproduced symptom, a measured or captured result, and the checks that guard it from returning.
Primary references
- Three.js manual: Cleanup
- React Three Fiber: Performance pitfalls
- React Three Fiber: Scaling performance