framesync
framesync is a browser render-loop scheduler from the Popmotion monorepo. It divides each animation frame into five ordered phases: read, update, preRender, render, and postRender. Callbacks in the same phase are deduplicated, recurring callbacks can stay alive across frames, and each receives a shared timestamp and clamped frame delta. Its main purpose is coordinating DOM reads and writes so unrelated code does not cause repeated synchronous layout.
framesync remains a neat solution when five shared render phases are already part of the design. For new code, prefer native requestAnimationFrame for a single loop or a currently maintained animation package for a full system.
Use it if
- You are maintaining Popmotion-era animation code that already uses framesync's five render phases
- Several independent components need one deterministic read-before-write frame loop
- You want recurring requestAnimationFrame work with cancellation and a shared, clamped delta
- You need to synchronously flush a named phase in tests or tightly controlled rendering code
- You only need one requestAnimationFrame callback; the browser API is clearer than adding a five-phase global scheduler
- You are starting a new Motion or Framer Motion integration: install the current higher-level package instead of depending directly on an old internal building block
- You need server-side scheduling: the implementation expects a frame source and is designed around browser rendering, not Node.js job queues
- You need task priorities, deadlines, idle work, promises, or error isolation; phases are fixed and callback exceptions are not caught by the scheduler
- You want active standalone maintenance: framesync 6.1.2 was published in August 2022, and the Popmotion monorepo's last push was in March 2024
Setup reality
npm install framesync provides ESM, CommonJS, and TypeScript declaration entry points plus one runtime dependency on tslib 2.4.0. The default export is the scheduling object; cancelSync, flushSync, and getFrameData are named exports. There is no provider, config file, CSS, or peer dependency. The important setup is architectural: every DOM measurement belongs in read, state calculation in update, and mutations in render or postRender. Mixing reads and writes inside one callback defeats the reason to use the package. Scheduling the same function more than once in a phase is deduplicated by function identity, so do not create a new arrow function if you plan to cancel it later. The second argument keeps a callback alive every frame until the matching cancelSync method receives that exact function. The third, immediate, only appends to the current phase when that phase is already processing; otherwise it behaves like a normal next-frame schedule. FrameData is one object recycled across frames, so destructure delta and timestamp before storing or using them asynchronously. Delta is clamped to 1 through 40 milliseconds after the initial default timestep, which prevents huge animation jumps but means it is not an exact wall-clock measurement. flushSync is a sharp tool: it manually processes a phase and reentrant flushes are deferred, so production code should rarely need it.
Patterns
Measure layout in the read phaseschedule-dom-read
import sync from 'framesync'
sync.read(() => {
const box = element.getBoundingClientRect()
latestWidth = box.width
})Keep layout reads together here and postpone style changes until render to avoid forcing layout repeatedly.
Apply a style in the render phaseschedule-dom-write
sync.render(() => {
element.style.transform = `translateX(${nextX}px)`
})render runs after read, update, and preRender in the same frame, so measurements should already be complete.
Read, calculate, and render in ordercoordinate-frame-phases
sync.read(() => {
width = element.getBoundingClientRect().width
})
sync.update(() => {
nextScale = targetWidth / width
})
sync.render(() => {
element.style.transform = `scaleX(${nextScale})`
})All callbacks use a global phase order; do not assume scheduling call order can put render before read.
Update from frame delta and timestampuse-frame-data
sync.update(({ delta, timestamp }) => {
position += velocity * (delta / 1000)
lastTimestamp = timestamp
})delta is clamped between 1 and 40 milliseconds after the first frame, so it is intended for stable animation rather than precise elapsed-time accounting.
Run an update every framekeep-process-alive
const tick = ({ delta }) => {
position += velocity * (delta / 1000)
}
sync.update(tick, true)The true second argument reschedules the same callback after each run until it is explicitly cancelled.
Stop a keep-alive callbackcancel-recurring-process
import sync, { cancelSync } from 'framesync'
const tick = () => updatePhysics()
sync.update(tick, true)
function stop() {
cancelSync.update(tick)
}Cancellation requires the same function object and the matching phase; a new arrow function will not cancel the scheduled one.
Append work to the phase currently runningschedule-current-step
sync.update(() => {
sync.update(() => finalizeState(), false, true)
})immediate only means the current frame when update is already processing. Outside that situation it schedules the next frame.
Defer nested work to the following frameschedule-next-frame
sync.render(({ timestamp }) => {
paintFirstState()
sync.render((next) => {
console.log(next.timestamp > timestamp)
paintSecondState()
})
})The default immediate value is false, so work scheduled into its currently running phase waits for the next frame.
Copy frame values before async useavoid-frame-object-retention
sync.postRender(({ delta, timestamp }) => {
queueMicrotask(() => {
sendMetric({ delta, timestamp })
})
})The FrameData object is reused and mutated every frame; retain primitive values, not the object reference.
Coalesce repeated scheduling by identitydeduplicate-callback
const renderPosition = () => {
element.style.transform = `translateX(${latestX}px)`
}
for (const x of pointerSamples) {
latestX = x
sync.render(renderPosition)
}A phase queues the same callback object once, so this renders the latest value without one callback per sample.
Flush a phase synchronously in a testflush-in-test
import sync, { flushSync } from 'framesync'
let called = false
sync.update(() => { called = true })
flushSync.update()
expect(called).toBe(true)flushSync bypasses normal frame timing. Reserve it for tests or controlled internals, not routine application rendering.
Read the scheduler's latest frame datainspect-current-frame
import { getFrameData } from 'framesync'
const { delta, timestamp } = getFrameData()
console.log({ delta, timestamp })getFrameData returns the shared mutable object. Destructure immediately if the values will outlive the current synchronous call.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| motion | npm | New animation work that wants the actively developed Motion APIs instead of a low-level Popmotion scheduler |
| raf-schd | npm | You only need to coalesce repeated calls and run the latest arguments once per animation frame |
| fastdom | npm | Your central problem is batching DOM measures and mutations with a familiar two-queue API |