v10 deployment.Frame Loop & Scheduler
React Three Fiber v10 ships with a rewritten frame loop. For most apps nothing changes — useFrame((state, delta) => { ... }) works exactly as before. Underneath, every callback is now a job scheduled by a single global scheduler that gives you named phases, explicit ordering, per-job fps throttling, pause/resume controls, composable render takeover, and demand/manual modes.
// Still works exactly the same
useFrame((state, delta) => {
meshRef.current.rotation.y += delta
})
The shared RAF loop
A single global singleton Scheduler drives one requestAnimationFrame loop for the entire application. Every <Canvas> (root) registers with this shared loop, so multiple canvases stay frame-synced instead of each spinning their own RAF. Each frame the scheduler walks its registered roots and executes their jobs in order.
The scheduler instance is a cross-bundle singleton (shared via Symbol.for), so mixing imports from @react-three/fiber and @react-three/fiber/webgpu still resolves to the same scheduler. The same key means it also survives hot module reloads — the loop keeps running while you edit rather than stopping dead on every save.
Each frame produces a state object passed to your callback:
interface FrameState extends RootState {
time: number // high-resolution RAF timestamp (milliseconds)
delta: number // time since last frame (seconds — THREE.Clock compatible)
elapsed: number // time since the first frame (seconds)
frame: number // incrementing frame counter
overstep: number // fraction of the next fixed physics step already banked, in [0, 1)
// ...plus all RootState (gl/renderer, scene, camera, etc.)
}
The delta passed as the second callback argument is the same value as state.delta. Inside a fixed-timestep phase such as physics, delta is the phase's timestep and elapsed is its simulated time.
Ordering Canvas roots
Order complete Canvas roots with the scheduler Canvas prop. Root ordering keeps every job owned by a Canvas together instead of ordering only its final render. It works on every entry, @react-three/fiber/legacy included:
<Canvas id="main" />
<Canvas id="secondary" scheduler={{ after: 'main', order: 1, fps: 40 }} />
The same options go to createRoot(canvas).configure({ scheduler }). A canvas sharing a <Canvas primary>'s renderer (see Multi-Canvas) renders after its primary unless you set before or after yourself.
before and after accept a Canvas id or an array of ids. References can mount in either order; an unresolved reference remains dormant and becomes active when that root registers. order provides numeric root ordering, with lower values running first.
fps is intentionally different: it throttles only that Canvas's default render job. Other jobs on the root, including useFrame updates, can continue each frame.
Phases
Jobs run inside named phases that execute in a fixed sequence each frame. The six default phases are:
A useFrame callback with no phase runs in update:
useFrame(() => {
/* runs in 'update' */
})
// Explicit, identical
useFrame(() => {}, { phase: 'update' })
Organize a game loop by assigning phases:
useFrame(processInput, { phase: 'input' })
useFrame((_, delta) => world.step(delta), { phase: 'physics' }) // delta is the fixed step, 1 / 60
useFrame(updateGameState, { phase: 'update' })
useFrame(renderEffects, { phase: 'render' })
useFrame(recordStats, { phase: 'finish' })
Custom phases
Add your own phases relative to existing ones with scheduler.addPhase(name, { before, after }):
const { scheduler } = useThree()
useEffect(() => {
// Insert an 'ai' phase between physics and update
scheduler.addPhase('ai', { after: 'physics', before: 'update' })
}, [scheduler])
useFrame(aiSystemUpdate, { phase: 'ai' })
Adding a phase with no position appends it to the end. Duplicate phase names are ignored with a warning. Inspect the current order with the scheduler.phases getter and check existence with scheduler.hasPhase(name). Pass timestep (and optionally maxSubsteps) to make the new phase a fixed-timestep phase like physics.
Ordering jobs
Within a phase, jobs are sorted by priority (higher priority runs earlier, default 0); jobs with equal priority run in registration order. before / after constraints between jobs take precedence over priority.
Don't rely on priority for order that matters. It exists mostly for legacy code and is only a loose hint: any other job can pick the same or a higher number. If a job actually needs to run before or after something, name that dependency with before / after.
For ordering across jobs, every job has a unique id (generated by the scheduler if you don't supply one). Reference an id — or a phase name — via before / after to create explicit dependencies:
// Camera updates first
useFrame(updateCamera, { id: 'camera' })
// Character runs after the camera job
useFrame(updateCharacter, { after: 'camera', id: 'character' })
// Enemies run after the character job
useFrame(updateEnemies, { after: 'character' })
before / after accept either a single name or an array, and a target can be a job id or a phase name:
useFrame(updateCharacter, { after: ['physics', 'input'] })
useFrame(prepRenderTargets, { before: 'render' })
When you use before/after without an explicit phase, the scheduler auto-resolves placement (creating implicit ordering around the target), keeping any default job in that phase intact — your work happens around it, not instead of it.
Migrating from priority numbers. v9's numeric priority is still accepted for backwards compatibility, but it is deprecated for controlling order or render takeover. Prefer phases or
before/after. Note that a numericpriority > 0retains its v9 meaning of "I am taking over rendering" and emits a deprecation notice — see render takeover. The sort direction is also reversed: v9 ran lower numbers first, while v10 maps the number to{ priority }and runs higher numbers first, so ordering-only values such as-1, 0run in the opposite order. A negative number emits a deprecation notice for this reason; migrate it to a phase or tobefore/after.Why the change. A single number can't compose across packages. Two libraries that both pick
0have no defined order between them, and there is no way for you to reorder a job you don't own — the number is baked into someone else's source. Naming the dependency instead (after: 'physics') states the constraint you actually care about, so the scheduler can resolve a correct order no matter what else is registered. Numeric priority survives mostly as a v9 shim, plus a loose tie-breaker for the rare case where order is a preference rather than a requirement.
FPS throttling
Not all work needs to run every frame. Set fps to cap how often a job runs:
useFrame(expensiveAI, { fps: 20 })
useFrame(particlePhysics, { fps: 30 })
useFrame(smoothAnimation) // no limit — runs every frame
A throttled job runs at most once per RAF. The scheduler computes the minimum interval (1000 / fps) and skips the job on frames where not enough time has elapsed (with ~1ms tolerance for RAF jitter). It never invokes the callback multiple times in a single frame to "catch up."
The drop option controls how the job's internal timestamp is advanced when it has fallen behind — it does not change how many times the callback fires:
drop: true(default) — snap the job's last-run time to now, discarding missed intervals. Good for visual / UI updates where only the latest state matters.drop: false— advance the last-run time by whole interval steps (catch-up semantics), keeping timing aligned for physics and simulations. The callback still fires at most once per frame; only the timestamp accounting differs (and is clamped to avoid runaway drift).
// Visual updates: discard missed frames
useFrame(updateUI, { fps: 30, drop: true })
// Simulation: keep interval timing consistent (still one call per frame)
useFrame(physicsStep, { fps: 60, drop: false })
fps throttles by wall clock, so it has no meaning in a fixed-timestep phase: a job in physics with fps set logs a warning and runs every step.
Fixed timestep and interpolation
The physics phase runs on a fixed timestep of 1/60 s. Each frame the root banks its frame delta, and the phase's jobs run once per whole step banked: zero to maxSubsteps times per frame (default 8, surplus time beyond that is dropped). Every call receives the step as delta (1 / 60), and state.elapsed is the phase's simulated time. So a simulation steps at the same rate on a 30 Hz, 60 Hz or 144 Hz display, but a physics job no longer runs exactly once per frame.
useFrame((_, delta) => world.step(delta), { phase: 'physics', id: 'world-step' })
Because steps and frames don't line up, drawing the latest physics state directly can stutter. state.overstep is the fraction of the next step already banked, in [0, 1); use it in a per-frame job to blend between the previous and current simulated states:
function Ball({ body }) {
const ref = useRef<THREE.Mesh>(null!)
const [previous, current] = useMemo(() => [new THREE.Vector3(), new THREE.Vector3()], [])
// Once per physics step, after the world has stepped
useFrame(
() => {
previous.copy(current)
current.copy(body.position)
},
{ phase: 'physics', after: 'world-step' },
)
// Once per frame: draw between the last two physics states
useFrame(({ overstep }) => ref.current.position.lerpVectors(previous, current, overstep))
return <mesh ref={ref}>{/* ... */}</mesh>
}
Change the step, or turn it off, with scheduler.setPhaseTimestep. Phases are shared by every root, and changing a timestep resets that phase's banked time. scheduler.getPhaseTimestep(name) reads the current value, and scheduler.getOverstep(phase?, rootId?) reads an overstep from outside a callback or for a second fixed phase.
const { scheduler } = useThree()
scheduler.setPhaseTimestep('physics', 1 / 120, { maxSubsteps: 4 }) // 120 Hz, at most 4 steps per frame
scheduler.setPhaseTimestep('physics', undefined) // opt out: once per frame with the frame delta
scheduler.addPhase('navigation', { after: 'physics', timestep: 1 / 10 }) // a custom fixed phase at 10 Hz
Pause, resume & enabled
useFrame returns a controls object for managing the job:
interface FrameControls {
id: string // the job's unique id
scheduler: Scheduler // the global scheduler
readonly rootId: string | undefined // the root currently owning this job
step(timestamp?: number): void // step this job only (bypasses fps limiting)
stepAll(timestamp?: number): void // step all jobs once
invalidate(frames?: number, stackFrames?: boolean): void // invalidate this job's root
pause(): void // pause this job (enabled = false)
resume(): void // resume this job (enabled = true)
isPaused: boolean // reactive — re-renders the component on change
}
isPaused is reactive (backed by useSyncExternalStore), so it updates the component when the job is paused or resumed from anywhere:
function PausableEffect() {
const controls = useFrame((state, delta) => {
// animation work
}, { id: 'particle-system' })
return (
<button onClick={() => (controls.isPaused ? controls.resume() : controls.pause())}>
{controls.isPaused ? 'Resume' : 'Pause'}
</button>
)
}
Control any job by id through the scheduler — handy for debug panels and pause menus:
const { scheduler } = useThree()
scheduler.pauseJob('particle-system')
scheduler.resumeJob('particle-system')
scheduler.isJobPaused('particle-system')
Resuming resets the job's timing so it doesn't try to account for the time it was paused.
For conditional execution, prefer the enabled option over an early return inside the callback. A disabled job stays registered but is skipped entirely by the scheduler — no wasted frame budget:
const isActive = useGameState()
useFrame(doWork, { enabled: isActive })
Render-phase takeover
By default R3F registers an internal system render job in the render phase that draws your scene each frame. When you register any useFrame job in the render phase, the default render is automatically skipped for that root — you have taken over rendering.
// You now control rendering for this root
useFrame(
() => {
effectComposer.render()
},
{ phase: 'render' },
)
// The built-in render is skipped while this job is registered
The check is dynamic each frame (scheduler.hasUserJobsInPhase('render')), so when your render job unmounts the default render resumes automatically — nothing to clean up.
To add render work without taking over, attach to the render phase with before / after instead. The default render still runs, and multiple components can compose around it:
// Prep before the default render
useFrame(updateUniforms, { before: 'render' })
// Overlay a HUD after the default render
useFrame(
({ gl }) => {
gl.autoClear = false
gl.render(hudScene, hudCamera)
gl.autoClear = true
},
{ after: 'render' },
)
The legacy v9 mechanism — a numeric
priority > 0onuseFrame— still disables the default render for backwards compatibility, but is deprecated. Migrate to{ phase: 'render' }.
Frameloop modes
The Canvas frameloop prop controls that Canvas's scheduler root. Every Canvas keeps its own mode and pending-frame count while sharing one RAF driver with the rest of the application.
frameloop="always" (default)
Continuous rendering — jobs run every RAF.
<Canvas frameloop="always">
<Scene />
</Canvas>
frameloop="demand"
Render only when explicitly requested via invalidate(). Ideal for static scenes that change occasionally.
import { useThree } from '@react-three/fiber'
function StaticScene() {
const { invalidate } = useThree()
return (
<mesh
onClick={() => {
updateSomething()
invalidate() // request one frame
}}
/>
)
}
frameloop="never"
The loop never runs on its own. Advance frames manually with the controls returned by useFrame, the standalone advance() util, or scheduler.step() — useful for tests, debugging, and non-realtime rendering.
function ManualScene() {
const { stepAll } = useFrame((state, delta) => {
// only runs when stepped
})
return <button onClick={() => stepAll()}>Step Frame</button>
}
Changing modes at runtime
const { setFrameloop } = useThree()
setFrameloop('demand') // store action — sets Canvas frameloop
setFrameloop updates only the owning Canvas. The scheduler starts the shared driver while any root has automatic work and stops it when every root is idle. scheduler.frameloop remains a legacy bulk/default control; use scheduler.setRootFrameloop(rootId, mode) when working with scheduler roots directly.
Demand mode: invalidate, onIdle & addTail
Demand and manual modes use a small set of utilities. onIdle and addTail callbacks fire when the shared driver has no automatic work left. An always root keeps the driver active even when every demand root is idle.
invalidate(frames?, stackFrames?)
Requests rendering in demand mode. Available as both a standalone export and from useThree().
import { invalidate } from '@react-three/fiber'
invalidate() // request a single frame
invalidate(undefined, 5) // request 5 frames (standalone signature)
The standalone export's signature is invalidate(state?, frames = 1, stackFrames = false). Passing a root state targets that root through scheduler.invalidateRoot; omitting it fans out through scheduler.invalidate. The useThree() action supplies its own state automatically. Notes:
- Only roots in
'demand'mode accumulate invalidations. - Pending frames are tracked per root and capped at 60.
- Each rendered frame decrements that root's pending count by 1.
- The shared loop stops when no root has automatic work left.
stackFrames: trueadds to the existing pending count instead of replacing it.
advance(timestamp)
Standalone util that manually runs one frame for all roots through scheduler.step(timestamp). The full signature is advance(timestamp, runGlobalEffects?, state?); passing a root state targets only that root through scheduler.stepRoot. Use it in frameloop="never" mode and in tests.
import { advance } from '@react-three/fiber'
advance(performance.now())
onIdle(callback)
Register a callback that fires when the demand-mode loop goes idle (pending frames hit 0). Returns an unsubscribe function.
function IdleHandler() {
const { scheduler } = useFrame()
useEffect(() => scheduler.onIdle((timestamp) => saveState()), [scheduler])
return null
}
Deprecated globals: addEffect / addAfterEffect / addTail
The v9 global hooks still work but are deprecated. They run once per frame (not per-root) and only receive the raw RAF timestamp — no gl/scene/camera and no seconds-based delta. Prefer phased useFrame:
Accessing the scheduler
// From useThree
const { scheduler } = useThree()
// From useFrame (with or without a callback)
const { scheduler } = useFrame()
// Inside a frame callback
useFrame(({ scheduler }) => {})
// Outside React
import { getScheduler } from '@react-three/fiber'
const scheduler = getScheduler()
Calling useFrame() with no callback is a valid way to get scheduler access without registering a job.
Using useFrame outside Canvas
useFrame does not throw when used outside <Canvas>, for example in a DOM component next to it. The first useFrame (or getScheduler().register) lazily creates an internal ambient root, so the job registers right away. R3F's useFrame always hands its callback the full RootState, though, so the callback is held until a <Canvas> mounts and adopts the job; from then on it runs every frame with that Canvas's state.
import { useFrame } from '@react-three/fiber'
// A DOM component beside the Canvas: runs once the Canvas mounts, with its state
function Hud() {
useFrame(({ camera }) => updateCompass(camera))
return <div className="compass" />
}
For a loop with no Canvas at all, register with the scheduler directly. Its callbacks receive timing-only state ({ time, delta, elapsed, frame }):
import { getScheduler } from '@react-three/fiber'
const unregister = getScheduler().register((state, delta) => updateGame(delta))
The scheduler itself lives in
@pmndrs/schedulerand is consumed by R3F; you still reach it throughgetScheduler()/useThree().scheduler.
Scheduler API reference
getScheduler() (or useThree().scheduler) returns the singleton. Key members:
Phases: addPhase(name, { before?, after?, timestep?, maxSubsteps? }), hasPhase(name), phases (getter), setPhaseTimestep(name, timestep | undefined, { maxSubsteps? }), getPhaseTimestep(name), getOverstep(phase?, rootId?).
Roots: registerRoot(id, options?), unregisterRoot(id), generateRootId(), getRootCount(), getRootIds(), getRootFrameloop(id), setRootFrameloop(id, mode), setRootOrder(id, order), setRootConstraints(id, { before?, after? }), isReady (getter), onRootReady(cb). Canvas manages these automatically. Each root gets its own onError (a Canvas reports to its error boundary), so a job that throws on one canvas never reaches another canvas's handler.
Jobs: register(callback, options?), unregister(id, rootId?), updateJob(id, partialOptions), getJobCount(), getJobIds(), hasUserJobsInPhase(phase, rootId?).
Job state: pauseJob(id), resumeJob(id), isJobPaused(id), subscribeJobState(id, listener).
Loop control: start(), stop(), isRunning (getter), frameloop (legacy bulk/default getter/setter), defaultFrameloop, invalidate(frames?, stackFrames?), invalidateRoot(rootId, frames?, stackFrames?), resetTiming().
Manual stepping: step(timestamp?) (run one frame for all roots), stepRoot(rootId, timestamp?) (run one root), stepJob(id, timestamp?) (run one job in isolation).
register accepts the full job options:
interface JobOptions {
id?: string // unique id (auto-generated if omitted)
rootId?: string // target root (defaults to the first root)
phase?: string // execution phase (default: 'update')
before?: string | string[] // run before this phase or job id
after?: string | string[] // run after this phase or job id
priority?: number // loose tie-breaker within a phase (higher = earlier, default 0); prefer before/after
fps?: number // throttle limit (at most one call per RAF)
drop?: boolean // timestamp catch-up behavior when throttled (default true)
enabled?: boolean // skip without unregistering (default true)
}
updateJob can mutate priority, fps, drop, and enabled without a rebuild; changing phase, before, or after triggers a rebuild of the sorted job list.
Example: a full game loop
function GameLoop() {
const { scheduler } = useThree()
useEffect(() => {
scheduler.addPhase('ai', { after: 'physics', before: 'update' })
}, [scheduler])
// Input first
useFrame(processInput, { phase: 'input', id: 'input-handler' })
// Physics on the fixed 1/60 s step (0..8 steps per frame)
useFrame((_, delta) => physicsWorld.step(delta), { phase: 'physics' })
// AI at 20fps, dropping missed intervals
useFrame(aiSystemUpdate, { phase: 'ai', fps: 20, drop: true })
// Game state every frame
useFrame(updateGameState, { phase: 'update', id: 'game-state' })
// VFX after game state
useFrame(updateVFX, { after: 'game-state' })
// HUD overlay after the default render (does not take over rendering)
useFrame(
({ gl }) => {
gl.autoClear = false
gl.render(hudScene, hudCamera)
gl.autoClear = true
},
{ after: 'render' },
)
// Stats last
useFrame(collectStats, { phase: 'finish' })
return null
}