Syncing CSS Animations with JavaScript Event Loops
Part of Animation State Management in Core CSS Animation Fundamentals.
The problem: phase drift under main-thread pressure
When CSS transitions fire concurrently with heavy JavaScript execution, the visual output decouples from the expected state — keyframes overlap, triggers arrive late, and the UI stutters. This phase drift is not a CSS bug. It is a scheduling conflict: CSS animations run on the compositor thread, but class-toggle logic lives in the JS event loop and depends on main-thread availability.
The diagram below shows exactly where the two threads diverge.
Root cause: two threads, one timing mismatch
CSS animations executing on transform and opacity run entirely on the compositor thread, bypassing the main thread. JavaScript tasks, microtasks, and rendering callbacks all queue on the main thread and process sequentially. When a setTimeout or setInterval drives a CSS class toggle, it depends on the main thread being free — not on the display’s vsync signal.
The browser’s vsync signal clocks the compositor at a fixed hardware interval (16.67ms at 60Hz, 8.33ms at 120Hz). The JS event loop has no hard tie to this clock. When a long task blocks the main thread, CSS continues playing on its own thread, but JS state updates stall. The class toggle arrives late, and the visual state jumps or overlaps.
Quantify this: measure the delta between performance.now() inside a requestAnimationFrame callback and the compositor’s frame submission time. If the variance consistently exceeds one frame (16.67ms on a 60Hz display), the main thread is being starved. This is the exact scenario that frame budgeting against the 16ms target addresses at the architecture level.
Rendering impact: main_thread — style recalculation and layout cannot begin while the call stack is occupied. Stale compositor frames accumulate until the main thread clears.
Step-by-step resolution
Step 1 — Capture a Performance trace
Open Chrome DevTools → Performance panel. Click Record, reproduce the stuttering interaction for three to five seconds, then stop.
- Switch to the Frames track. Red bars indicate dropped frames.
- Filter the Main thread flame chart for Task and Animation entries.
- Look for red triangles on tasks exceeding 50ms — these are the long tasks starving the compositor.
- Enable Paint flashing (Rendering drawer) to see whether non-composited properties are forcing rasterization mid-animation.
If the Animation timeline shows continuous playback while the Frames track shows red bars, the synchronization mechanism is not aligned with the vsync cadence.
Step 2 — Identify whether CSS or JS owns the timing problem
| Symptom | Cause | Fix |
|---|---|---|
| Animation runs but JS event fires late | Main thread occupied when transitionend fires |
Wrap handler in requestAnimationFrame |
| Class toggle triggers but animation skips frames | Long task before toggle | Break long task into microtask chunks |
WAAPI currentTime drifts from CSS progress |
Separate timing sources | Read document.timeline.currentTime as shared clock |
| Dropped frames only on scroll | Scroll listener on main thread | Switch to ScrollTimeline or IntersectionObserver |
Step 3 — Replace setTimeout / setInterval with requestAnimationFrame
setTimeout resolves when its timer fires and the call stack is empty — which can be many milliseconds after the intended frame. requestAnimationFrame is called by the browser exactly before the next repaint, making it the correct tool for CSS class mutations you want frame-aligned.
/**
* Defers a CSS class toggle to the pre-paint phase to align with vsync.
* Bypasses setTimeout drift by scheduling via requestAnimationFrame.
*/
function syncClassToggle(element, className) {
// Respect prefers-reduced-motion: skip the rAF delay, apply immediately
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
element.classList.toggle(className);
return;
}
// rAF fires just before the browser calculates the next frame —
// the toggle lands at the compositor boundary, not whenever the stack clears.
requestAnimationFrame(() => {
element.classList.toggle(className);
});
}
Rendering impact:
composite— the class toggle reaches style calculation at the start of the next frame cycle, keeping the compositor and JS state in lockstep.
Step 4 — Use WAAPI for direct compositor timeline access
The Web Animations API (element.animate()) is the most precise synchronization tool available. It attaches animations directly to the document timeline, lets you read and write currentTime, and exposes getAnimations() to query state without triggering layout. This goes further than class toggling because it gives JS explicit ownership of the compositor clock.
/**
* WAAPI frame-aligned playback control.
* Attaches an animation directly to the compositor timeline.
* JS reads and sets currentTime without touching layout-triggering properties.
*/
const animation = element.animate(
[
{ opacity: 0, transform: 'translateY(8px)' },
{ opacity: 1, transform: 'translateY(0)' }
],
{ duration: 1200, fill: 'forwards', easing: 'cubic-bezier(0.4, 0, 0.2, 1)' }
);
// Immediately resolve to end state when reduced motion is preferred
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
animation.finish();
}
/**
* Correct drift whenever JS-driven state diverges from the intended playhead.
* Only adjusts if drift exceeds one frame (16.67ms) to avoid micro-jitter.
*/
function correctDrift(targetTime) {
const delta = targetTime - animation.currentTime;
if (Math.abs(delta) > 16.67) {
animation.currentTime = targetTime;
}
}
// Align correction to the next frame boundary, not to an arbitrary callback
function onStateChange(intendedTime) {
requestAnimationFrame(() => correctDrift(intendedTime));
}
Rendering impact:
composite—element.animate()bypasses style recalculation and layout entirely fortransformandopacity. Mutations land on the GPU without main-thread involvement.
Step 5 — Bind animationiteration / transitionend inside rAF
CSS event callbacks (animationend, transitionend, animationiteration) fire on the main thread after the compositor has already advanced. If you need JS state to update in response to a CSS event and that update must trigger the next animation frame cleanly, wrap the handler body in requestAnimationFrame to re-queue the mutation at the next boundary.
/**
* Routes a transitionend state update through rAF to prevent the mutation
* from landing mid-composite-cycle and causing a stale-frame flash.
*/
element.addEventListener('transitionend', (event) => {
if (event.propertyName !== 'opacity') return;
// Re-schedule the state update so it lands at the next frame start
requestAnimationFrame(() => {
// Safe to update data-state here; compositor is between frames
element.dataset.state = 'idle';
});
});
Rendering impact:
main_thread— thedata-stateattribute update triggers a style recalculation, but scheduling it via rAF ensures it does not compete with an in-progress composite cycle.
Verification checklist
Constraints and trade-offs
- Compositor thread isolation breaks on non-composited properties. Animating
width,height,margin, ortopforces synchronous layout recalculation on every frame, bypassing the compositor entirely. Restrict animated properties to the hardware-accelerated properties that guarantee GPU offload. will-changeoveruse exhausts GPU memory. Promoting every animated element to its own compositor layer consumes VRAM. Applywill-change: transformonly to elements that are actively animating, and remove it once the animation completes.- WAAPI browser support is broad but
ScrollTimelineis not universal. For scroll-linked variants, polyfill or useIntersectionObserveras a fallback to avoid attaching scroll listeners on the main thread. - Background tab throttling desynchronizes
document.timeline. Browsers throttle rAF and some timers in hidden tabs. Validateanimation.playStatewhen the page becomes visible again via thevisibilitychangeevent and resync if needed. - Reduced-motion fallbacks change the sync model. When
prefers-reduced-motion: reduceresolves, WAAPI’sanimation.finish()snaps to the end state, bypassing the synchronization path. Any downstream JS that read intermediatecurrentTimevalues must handle this instant-completion scenario.
FAQ
Why does requestAnimationFrame not perfectly align with CSS @keyframes?
rAF schedules its callback before the next repaint, but CSS animations run on the compositor thread using the hardware vsync signal as their clock. Without using the Web Animations API’s currentTime to bridge the two, JS and CSS each follow their own timing source. The gap is usually sub-frame, but under main-thread load it compounds into visible drift.
How do I measure exact frame drift between JS and CSS?
Record a Performance trace and compare the Frames track timestamps against the Animation timeline in the same trace. Export the trace as JSON and diff frameStartTime against animationFrameTime for each frame. A consistent delta above 16.67ms at 60Hz means the main thread is delayed past the vsync window. You can also instrument this at runtime: capture performance.now() at the top of an rAF callback, then compare it to animation.currentTime via WAAPI.
Does the Web Animations API replace CSS animations entirely?
No. CSS is still the right choice for declarative, compositor-safe animations — the browser optimizes them automatically and they survive across style recalculations without JS involvement. WAAPI adds the synchronization and inspection surface that CSS alone cannot provide. The practical pattern is CSS for the motion, WAAPI or rAF for the state handshake.
Can I sync multiple elements to the same compositor clock?
Yes. Read document.timeline.currentTime once inside a single rAF callback, then set animation.currentTime on every element to that value before the callback returns. This pins all WAAPI instances to an identical timestamp, eliminating per-element drift regardless of when each animation was created.
Related
- Animation State Management — the parent topic: state machine architecture, queue management, and event loop coordination patterns
- Avoiding layout thrashing in CSS animations — how forced synchronous layouts break the frame budget and how to batch DOM reads/writes
- Frame Budgeting & 16ms Targets — numeric performance targets and profiling strategies for staying within the compositor’s frame window