FrameConductor
Ten uncoordinated requestAnimationFrame loops fight for a single 16.7ms frame budget and interleave layout reads with DOM writes. FrameConductor gives every effect one shared loop with three ordered phases (input → update → render) so layout computes at most once per frame.
The FrameConductor is accessed via the getConductor() entry point. Most components interact with it through higher-level hooks such as useTick, useSensorBus, and useSceneGate. Reach for getConductor() directly when writing custom canvas engines, WebGL renderers, or physics loops that must coordinate with the rest of the application without spinning uncoordinated loops.
Subscribers execute in three strictly ordered phases each frame: input (passive sensor and layout reads) → update (spring mathematics, damping, and simulation) → render (DOM transform writes, WebGL draws, and canvas paint). Every read finishes before any write begins, ensuring the browser recomputes layout at most once per frame.
The animation loop initializes on the first subscriber and automatically calls cancelAnimationFrame when all subscribers unmount, consuming zero CPU cycles when idle.
◆One loop, three lanes
Quick start
Subscribe in an effect. The callback receives (dt, time) in seconds; return the unsubscribe function directly to useEffect so work stops cleanly on unmount.
The loop starts with this subscriber and stops when it unmounts. There is no idle animation loop burning CPU in the background.
The three lanes
Every frame runs in the exact same phase sequence regardless of subscription order. A render callback always runs after every input and update callback in the same frame.
| Lane | Use it for | Avoid |
|---|---|---|
| input | Reading pointer, scroll, layout rects, sensors, telemetry | DOM or style writes |
| update | Damping, spring physics, matrix interpolation, prediction | DOM reads and writes |
| render | CSS transforms, canvas 2D draws, WebGL uniforms | Layout reads (getBoundingClientRect) |
API Reference
Syntax
The subscribe() method registers a callback to run once per animation frame in the designated lane.
Parameters
| Parameters.field | Type | Default | Description |
|---|---|---|---|
| lane | "input" | "update" | "render" | (required) | The execution lane for this callback. |
| fn | (dt: number, time: number) => void | (required) | dt is elapsed seconds since this subscriber last ran. time is the global clock timestamp in seconds. |
| options.priority | "essential" | "enhanced" | "decorative" | "enhanced" | Shed order when a frame runs long. essential is never shed; enhanced sheds once 70% of the budget is spent; decorative sheds at 45%. |
| options.hz | number | every frame | Throttle subscriber to a lower cadence (e.g. 30Hz). dt accumulates across skipped frames so spring physics remains 100% accurate. |
| options.label | string | "anonymous" | Human-readable name displayed in DevTools inspector and slow-subscriber warnings. |
| options.scope | string | undefined | Interaction scope ID this work belongs to. Background work yields earlier when an active foreground scope holds priority. |
Return Value
The unsubscribe function. Removes callback from the subscriber set. When the subscriber count drops to 0, cancelAnimationFrame is called and the engine sleeps.
Properties & State
| Properties.field | Type | Description |
|---|---|---|
| state | ConductorStats | Real-time snapshot of the engine: running state, display Hz, fps, frameMs, workMs, worstWorkMs, carriedOverrunMs, preRuntimeMs, activeScope, subscriberCount, shedLastFrame, and subscribers. |
ConductorStats
| ConductorStats.field | Type | Description |
|---|---|---|
| running | boolean | Whether the loop is alive. False when 0 subscribers are registered. |
| displayHz | number | Detected display refresh rate (e.g. 60, 120, 144Hz). |
| frameBudgetMs | number | One presented frame in ms (16.7ms at 60Hz, 8.3ms at 120Hz). |
| fps | number | Smoothed frames per second measured from rAF interval. |
| frameMs | number | Smoothed interval between presented frames, including third-party work. |
| workMs | number | Smoothed time this runtime spent executing subscribers. |
| worstWorkMs | number | Decaying peak of workMs to track worst-case frame spikes. |
| carriedOverrunMs | number | Overrun debt carried from the previous frame and subtracted from the current budget. |
| preRuntimeMs | number | Time elapsed before the runtime received the frame tick (e.g. third-party script lag). |
| activeScope | string | null | Foreground interaction scope currently holding priority. |
| subscriberCount | number | Total active subscribers across input, update, and render. |
| shedLastFrame | number | Subscribers skipped on the most recent frame due to budget limits. |
| subscribers | SubscriberStat[] | Per-subscriber profiling breakdown, sorted by cost. |
Global Configuration: configure()
Configures global runtime policy across all subscribers on the page. Call it once at application startup.
| ConductorConfig.field | Type | Default | Description |
|---|---|---|---|
| shedding | boolean | true | Drop low-priority work when a frame runs long. Set false only for deterministic video rendering or offline benchmarks. |
| onError | (error, label, lane) => void | console.error | Invoked when a subscriber throws. The loop carries on; use this to wire error monitoring like Sentry. |
| slowSubscriberMs | number | 0 (off) | Warn once per subscriber that exceeds this run time in ms, naming it by its label. |
Unsubscribing & Lifecycle Cleanup
To prevent memory leaks and ensure the frame engine goes to sleep when components unmount, always store or return the unsubscribe function returned by subscribe().
Lifecycle & Invariant Guarantees
- →Zero idle cost: The rAF loop starts with the first subscriber and calls cancelAnimationFrame after the last unsubscribe. There is no background loop burning CPU.
- →Fixed lane order: input always finishes before update runs, and update always finishes before render writes to the DOM or GPU. Layout calculates at most once per frame.
- →Delta time is clamped: dt is capped at 0.1s. When a user backgrounds a browser tab for 30 seconds and switches back, it steps forward by 100ms instead of teleporting spring chains.
- →Error isolation: An exception in one callback is caught and reported to onError, preventing one failing effect from halting other subscribers.
- →Nothing starves: A subscriber shed four frames in a row is forced through on the fifth frame regardless of budget. Low-priority work degrades to a lower cadence (such as 15fps) rather than freezing indefinitely.
- →Safe lifecycle edits: A subscriber can safely unsubscribe itself or add another subscriber during a tick without skipping or corrupting the current frame iteration.
◆Production Examples
Battle-tested production patterns ready to copy directly into your codebase.
The canonical two-lane split: read the world in input, write the DOM in render. Nothing touches React state.
When NOT to use this
Rules & Gotchas
- →Keep callbacks small: The conductor is a single main-thread pipeline; expensive work in one callback delays every subscriber queued after it.
- →Direct DOM Mutation over React State: Store changing values in variables or refs and mutate DOM styles or WebGL uniforms directly. Storing 60fps frame values in useState triggers continuous React reconciliation.
- →Read in input, write in render: Reading DOM layout in the render phase or writing DOM in the input phase reintroduces layout thrashing.
- →Pair decorative tracking with hz: 30: Ambient background particles or decorations can run at 30Hz with hz: 30, halving CPU cost while preserving smooth physics.