Appearance
Scaling Performance
FreshRunning WebGL can be quite expensive depending on how powerful your devices are. To make your application available to a broad variety of devices, look into performance optimizations.
On-Demand Rendering
three.js apps usually run in a game-loop that executes 60 times a second — R3F is no different. This is perfectly fine when your scene has constantly moving parts, but it drains batteries and makes fans spin up when things are at rest.
Opt into on-demand rendering to only render when necessary:
jsx
<Canvas frameloop="demand">It will render frames whenever it detects prop changes throughout the component tree.
Triggering Manual Frames
One major caveat is that if anything in the tree mutates props, React cannot be aware of it and the display would be stale. For instance, camera controls grab into the camera and mutate its values. Use invalidate to trigger frames manually:
jsx
function Controls() {
const orbitControlsRef = useRef()
const { invalidate, camera, gl } = useThree()
useEffect(() => {
orbitControlsRef.current.addEventListener('change', invalidate)
return () => orbitControlsRef.current.removeEventListener('change', invalidate)
}, [])
return <orbitControls ref={orbitControlsRef} args={[camera, gl.domElement]} />INFO
Drei's controls do this automatically for you.
You can call invalidate whenever you need to render:
jsx
invalidate()WARNING
Calling invalidate() will not render immediately — it merely requests a new frame. Calling it multiple times will not render multiple times. Think of it as a flag to tell the system that something has changed.
Sync Animations with On-Demand Rendering
Since invalidate() is only a flag, you might bump into syncing issues when you run animations that start immediately. Pre-emptively schedule a render then start the animation in the next frame:
jsx
<mesh
onClick={() => {
// Pre-emptively schedule a render
invalidate()
// Wait for the next frame to start the animation
requestAnimationFrame(() => controls.dolly(1, true))
}}Re-Using Geometries and Materials
Each geometry and material means additional overhead for the GPU. Re-use resources if you know they will repeat:
jsx
const red = new THREE.MeshLambertMaterial({ color: "red" })
const sphere = new THREE.SphereGeometry(1, 28, 28)
function Scene() {
return (
<>
<mesh geometry={sphere} material={red} />
<mesh position={[1, 2, 3]} geometry={sphere} material={red} />If you create a material or color in global space — outside of R3F's Canvas context — enable ColorManagement in three.js:
jsx
import * as THREE from 'three'
// r150+
THREE.ColorManagement.enabled = true
// r139-r149
THREE.ColorManagement.legacyMode = falseCaching with useLoader
INFO
Every resource that is loaded with useLoader is cached automatically!
If you access a resource via useLoader with the same URL, throughout the component tree, you will always refer to the same asset and thereby re-use it. This is especially useful with GLTFJSX because it links up geometries and materials:
jsx
function Shoe(props) {
const { nodes, materials } = useLoader(GLTFLoader, "/shoe.glb")
return (
<group {...props} dispose={null}>
<mesh geometry={nodes.shoe.geometry} material={materials.canvas} />
</group>
)
}
<Shoe position={[1, 2, 3]} />
<Shoe position={[4, 5, 6]} />Both instances share the same cached geometry and material — only loaded once.
Instancing
Each mesh is a draw call. You should be mindful of how many of these you employ: no more than 1000 as the very maximum, and optimally a few hundred or less. Win performance back by reducing draw calls through instancing:
jsx
function Instances({ count = 100000, temp = new THREE.Object3D() }) {
const instancedMeshRef = useRef()
useEffect(() => {
// Set positions
for (let i = 0; i < count; i++) {
temp.position.set(Math.random(), Math.random(), Math.random())
temp.updateMatrix()
instancedMeshRef.current.setMatrixAt(i, temp.matrix)
}
// Update the instance
instancedMeshRef.current.instanceMatrix.needsUpdate = true
}, [])
return (
<instancedMesh ref={instancedMeshRef} args={[null, null, count]}>
<boxGeometry />
<meshPhongMaterial />
</instancedMesh>
)
}This renders 100,000 objects in a single draw call!
Level of Detail
Sometimes it is beneficial to reduce the quality of an object the further it is from the camera. Drei's <Detailed /> component sets up LOD without boilerplate:
jsx
import { Detailed, useGLTF } from '@react-three/drei'
function Model() {
const [low, mid, high] = useGLTF(["/low.glb", "/mid.glb", "/high.glb"])
return (
<Detailed distances={[0, 10, 20]}>
<mesh geometry={high} />
<mesh geometry={mid} />
<mesh geometry={low} />
</Detailed>
)
}Load or prepare a couple of resolution stages, as many as you like, then give them the same amount of distances from the camera, starting from highest quality to lowest.
Nested Loading
Load lesser textures and models first, higher-resolution later. Three loading stages: loading indicator, low quality, high quality:
jsx
function App() {
return (
<Suspense fallback={<span>loading...</span>}>
<Canvas>
<Suspense fallback={<Model url="/low-quality.glb" />}>
<Model url="/high-quality.glb" />
</Suspense>
</Canvas>
</Suspense>
)
}
function Model({ url }) {
const { scene } = useGLTF(url)
return <primitive object={scene} />
}Performance Monitoring
Drei has a PerformanceMonitor component that allows you to monitor and adapt to device performance. It collects average fps over time and triggers onIncline and onDecline callbacks.
A simple example for regulating the resolution:
jsx
function App() {
const [dpr, setDpr] = useState(1.5)
return (
<Canvas dpr={dpr}>
<PerformanceMonitor onIncline={() => setDpr(2)} onDecline={() => setDpr(1)} >Use onChange for gradual changes via a factor between 0 and 1:
jsx
import round from 'lodash/round'
const [dpr, setDpr] = useState(1)
return (
<Canvas dpr={dpr}>
<PerformanceMonitor onChange={({ factor }) => setDpr(round(0.5 + 1.5 * factor, 1))}>Limit flip-flops with a fallback:
jsx
<PerformanceMonitor flipflops={3} onFallback={() => setDpr(1)}>Use usePerformanceMonitor for individual components to respond to performance changes:
jsx
<PerformanceMonitor>
<Effects />
</PerformanceMonitor>
function Effects() {
usePerformanceMonitor({ onIncline, onDecline, onFallback, onChange })
// ...
}Movement Regression
To keep your scene fluid at 60fps regardless of device or model complexity, regress movement — reduce effects, textures, and shadows quality temporarily during movement, then restore on still-stand.
When you inspect the state model you will notice a performance object:
jsx
performance: {
current: 1,
min: 0.1,
max: 1,
debounce: 200,
regress: () => void,
},current— Performance factor alternates between min and maxmin— Performance lower bound (should be less than 1)max— Performance upper bound (no higher than 1)debounce— Debounce timeout until it goes to upper bound (1) againregress()— Function that temporarily regresses performance
Define defaults:
jsx
<Canvas performance={{ min: 0.5 }}>...</Canvas>Call regress() when the scene is moving — for instance when controls fire their change event:
jsx
const regress = useThree((state) => state.performance.regress)
useEffect(() => {
controls.current?.addEventListener('change', regress)Respond to regression by scaling the pixel ratio:
jsx
function AdaptivePixelRatio() {
const current = useThree((state) => state.performance.current)
const setPixelRatio = useThree((state) => state.setDpr)
useEffect(() => {
setPixelRatio(window.devicePixelRatio * current)
}, [current])
return null
}Drop this component into the scene and combine it with regress() for adaptive resolution.
Enable Concurrency
React 18 introduces concurrent scheduling via startTransition and useTransition. This allows you to prioritize components and actions.
Since version 8 of Fiber, canvases use concurrent mode by default — React will schedule and defer expensive operations. You can play around with the experimental scheduler to see if marking ops with a lesser priority makes a difference.
jsx
import { useTransition } from 'react'
const [isPending, startTransition] = useTransition()Benchmark results show that React's concurrency can maintain 60fps even while constructing hundreds of expensive objects that would otherwise cause 1.5 seconds of jank in vanilla three.js.
For more details, see the Pitfalls page — specifically the startTransition section.