smooth animations without jank
requestAnimationFrame is the only correct way to implement animations in the browser. Anyone using setTimeout or setInterval is fighting the rendering cycle instead of working with it. This article shows how rAF works, how to build a robust render loop, and which traps destroy the frame rhythm.
Table of Contents
- 1. Why requestAnimationFrame instead of setTimeout?
- 2. Understanding the browser rendering pipeline
- 3. Writing your first render loop
- 4. The DOMHighResTimeStamp parameter
- 5. cancelAnimationFrame and clean cleanup
- 6. Avoiding frame budget and layout thrashing issues
- 7. Frame throttling for low-frequency updates
- 8. rAF vs. CSS animations vs. Web Animations API
- 9. Practical example: scroll progress indicator
- 10. Summary
- 11. FAQ
1. Why requestAnimationFrame instead of setTimeout?
The intuitive answer to the question "How do I animate something in JavaScript?" is, for many developers, setInterval(update, 16), because 1000 ms / 60 fps ≈ 16 ms. That math sounds plausible, but it is fundamentally wrong. requestAnimationFrame synchronizes callback execution with the display's refresh cycle, while setInterval operates completely blind to the browser renderer. The result with setInterval: the browser renders a frame while JavaScript is in the middle of computing a DOM mutation, which leads to incomplete frames that become visible as jank (stuttering).
Another critical problem: setInterval keeps running when the tab is in the background. That wastes CPU resources and battery on mobile devices. requestAnimationFrame automatically pauses when the tab is not visible, and picks the rhythm back up seamlessly once visibility is restored. Browsers can also adapt rAF to the native display frequency, whether 60 Hz, 90 Hz or 120 Hz, without the developer having to adjust anything. Together, these three advantages make requestAnimationFrame the only defensible choice for JavaScript animations.
2. Understanding the browser rendering pipeline
To use requestAnimationFrame correctly, you need to understand where it sits in the browser rendering pipeline. A frame begins with input events (mouse, touch, keyboard), after which rAF callbacks are executed. Then follow style recalculation, layout, paint and composite. requestAnimationFrame callbacks therefore deliberately run before the render step, giving the developer a chance to change the DOM and CSSOM before the browser computes the frame. A change made inside a rAF callback is guaranteed to be visible to the user in the next frame.
The rule of thumb for the 16 ms frame budget at 60 Hz: the rAF callback plus all resulting style/layout calculations together must stay under 10 ms, to leave the browser enough time for paint and composite. Exceed that budget and a frame gets skipped, the "dropped frame" that is perceived as stuttering. requestAnimationFrame gives no guarantee that every callback runs on every frame: if execution takes too long, the browser prioritizes the render step and pushes the next rAF callback back.
3. Writing your first render loop
The basic pattern for a requestAnimationFrame loop consists of a function that re-registers itself via requestAnimationFrame at the end. This recursive-looking structure is not real recursion: every call returns immediately and the browser invokes the callback again at the next frame's timing. The pattern is deliberately kept simple so the browser can schedule the callback at a time that suits it best.
It is important to keep the loop state in an outer variable, not inside the callback. Animation properties such as current position, velocity and direction live in the closure scope. The callback itself computes the new state based on the elapsed delta and writes the result to the DOM. Reading DOM properties and writing to the DOM are separate operations: never alternate reads and writes within the same callback, or you will provoke layout thrashing.
// Basic requestAnimationFrame render loop with state management
let rafId = null;
const state = {
x: 0,
velocity: 2, // pixels per frame (60 fps baseline)
maxX: 800,
};
function update(timestamp) {
// Move element, reverse direction at boundaries
state.x += state.velocity;
if (state.x >= state.maxX || state.x <= 0) {
state.velocity *= -1;
}
// Write to DOM, only after all reads are done
element.style.transform = `translateX(${state.x}px)`;
// Schedule next frame, loop continues until cancelled
rafId = requestAnimationFrame(update);
}
// Start loop
rafId = requestAnimationFrame(update);
// Stop loop (e.g. on component unmount or user pause)
function stopLoop() {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
4. The DOMHighResTimeStamp parameter
Every requestAnimationFrame callback receives, as its first argument, a DOMHighResTimeStamp, a high-resolution time marker in milliseconds relative to the page load. This time marker is identical for all rAF callbacks executed within the same frame flush. That means: multiple independent requestAnimationFrame loops active in the same frame receive the same timestamp. That enables precise synchronization between different animation components without explicit communication.
The decisive advantage over Date.now() lies in accuracy and in decoupling from the system clock. The timestamp is guaranteed to be monotonically increasing and is unaffected by system clock adjustments. For frame-rate-independent animations you compute the delta relative to the previous frame (delta = timestamp - lastTimestamp) and multiply the motion by that delta. That way the animation runs twice as often on a 120 Hz display, but at half the step size, so the result is the same perceived speed across all display frequencies.
5. cancelAnimationFrame and clean cleanup
requestAnimationFrame returns a numeric handle that is required for cancelAnimationFrame(handle). Anyone who does not store this handle cannot stop the loop from the outside. This is the most common memory leak pattern in rAF-based animations: a React component or a web component is removed, but the loop keeps running because the handle was never stored in the cleanup logic.
The safe pattern: always store the handle in a variable outside the callback. Clean it up in the useEffect cleanup in React components. In web components, clean it up in disconnectedCallback. On page visibility changes (document.addEventListener('visibilitychange', ...)), pause the loop when the tab becomes invisible. requestAnimationFrame already does this automatically at the system level, but explicit pausing also prevents unnecessary state calculations in JavaScript itself.
6. Avoiding frame budget and layout thrashing issues
Layout thrashing is the most common performance killer in requestAnimationFrame loops. It occurs when JavaScript alternately reads and writes DOM properties: the browser must recalculate layout after every write operation before it can return a read. A loop that reads el.offsetWidth and then sets el.style.width for ten elements forces ten layout recalculations within a single frame, instead of a single one at the end.
The solution follows the batch pattern: all reads first, then all writes. In complex animation scenarios, FastDOM or a manual read/write queue helps. For purely visual transformations, transform and opacity are the first choice, because both properties run on the compositor thread and trigger no layout. Changes to width, height, top or left, on the other hand, always trigger layout and should be avoided in animation loops.
// Frame-rate-independent animation using DOMHighResTimeStamp delta
let lastTimestamp = null;
let rafId = null;
let posX = 0;
const SPEED_PX_PER_MS = 0.3; // 300 px/s, consistent across 60/120 Hz
function animate(timestamp) {
if (lastTimestamp === null) {
lastTimestamp = timestamp; // First frame: initialize
}
const delta = timestamp - lastTimestamp; // ms since last frame
lastTimestamp = timestamp;
// Cap delta to avoid huge jumps after tab was hidden
const safeDelta = Math.min(delta, 100);
posX = (posX + SPEED_PX_PER_MS * safeDelta) % 800;
// GOOD: use transform, no layout, runs on compositor thread
element.style.transform = `translateX(${posX.toFixed(2)}px)`;
rafId = requestAnimationFrame(animate);
}
// Pause when tab is hidden, saves CPU
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
cancelAnimationFrame(rafId);
rafId = null;
lastTimestamp = null; // Reset so delta is clean on resume
} else {
rafId = requestAnimationFrame(animate);
}
});
rafId = requestAnimationFrame(animate);
7. Frame throttling for low-frequency updates
Not every task needs 60 updates per second. A scroll progress bar, a canvas visualization of real-time data, or an audio visualizer can often be driven at 30 fps or less. Frame throttling with requestAnimationFrame is the clean solution: the loop keeps running at the native frame rate, but the actual update code only executes once the accumulated delta exceeds the desired interval value.
This pattern is superior to the naive setInterval approach because the rAF loop stays synchronized with the browser's rendering cycle. When an update is skipped because the delta is not yet sufficient, the callback returns immediately, with no DOM writes and no layout calculation. That is significantly cheaper than a setInterval that fires independent of the rendering cycle, whose updates might collide with an in-progress paint step.
8. rAF vs. CSS animations vs. Web Animations API
The choice between requestAnimationFrame, CSS animations and the Web Animations API depends on the use case. CSS animations and CSS transitions are the first choice for simple, declaratively describable effects: they run on the compositor thread, need no JavaScript, and are the most performant option. But as soon as the animation has to react to real-time data, user input, or complex physics calculations, CSS is no longer enough.
The Web Animations API (element.animate()) offers programmatic access to CSS animation mechanisms and is ideal for many mid-level complexity cases. It provides playback control (play, pause, reverse, cancel) and promise-based completion callbacks. requestAnimationFrame is the lowest level: maximum control, maximum effort. For canvas rendering, WebGL, complex particle systems, or physics engines, there is no alternative to rAF.
| Method | Thread | Control | Ideal for |
|---|---|---|---|
| CSS Transition/Animation | Compositor | Low (declarative) | Hover effects, simple transitions |
| Web Animations API | Compositor | Medium (playback control) | Sequences, pause/play, async |
| requestAnimationFrame | Main Thread | Maximum (imperative) | Canvas, physics, real-time data |
| setInterval/setTimeout | Main Thread | Low (unsynced) | Not suitable for animations |
9. Practical example: scroll progress indicator
A scroll progress indicator is the ideal practical example for requestAnimationFrame: the progress bar should react smoothly without processing every single scroll event directly. The pattern: scroll events only set a flag or store the current scroll value. The rAF loop reads these values once per frame and updates the display. That way, several scroll events occurring between two frames get collapsed into a single DOM update.
This approach is known as "passive event listener with rAF scheduling" and is the recommended method for all scroll, mouse and resize handlers that trigger DOM changes. The scroll event listener is registered as passive: true, which signals to the browser that the handler does not call preventDefault(). That allows the browser to continue scrolling on the compositor thread without having to wait for the JavaScript main thread, which avoids scroll jank entirely.
// Scroll-progress indicator, passive listener + rAF scheduling
class ScrollProgress {
constructor(barElement) {
this.bar = barElement;
this.rafId = null;
this.scrollY = 0;
this.ticking = false;
// Passive: browser need not wait for JS before scrolling
window.addEventListener('scroll', this.onScroll.bind(this), { passive: true });
}
onScroll() {
this.scrollY = window.scrollY;
// Only schedule one rAF per frame, guard with ticking flag
if (!this.ticking) {
this.rafId = requestAnimationFrame(this.update.bind(this));
this.ticking = true;
}
}
update() {
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = docHeight > 0 ? this.scrollY / docHeight : 0;
// transform: scaleX, no layout, compositor-only
this.bar.style.transform = `scaleX(${progress})`;
this.ticking = false;
}
destroy() {
window.removeEventListener('scroll', this.onScroll);
if (this.rafId) cancelAnimationFrame(this.rafId);
}
}
const progressBar = document.getElementById('progress-bar');
progressBar.style.transformOrigin = 'left center';
const indicator = new ScrollProgress(progressBar);
10. Summary
requestAnimationFrame is the central tool for any JavaScript animation that goes beyond simple CSS transitions. It synchronizes callbacks with the browser's rendering cycle, pauses on hidden tabs, and automatically adapts to the native display frequency. The DOMHighResTimeStamp parameter enables frame-rate-independent animations via delta calculations. cancelAnimationFrame is not an optional cleanup step, it is mandatory to avoid memory leaks in component architectures.
The most important guidelines: read before writing to avoid layout thrashing. Prefer transform and opacity, because they run on the compositor thread. Delegate scroll and input handlers to rAF scheduling instead of performing DOM operations directly. Use frame throttling when 60 fps is overkill for the use case. And always: store the loop handle and clean up properly.
Mironsoft
JavaScript performance, frontend architecture and browser APIs
Animations that actually run smoothly?
We analyze existing animation implementations, spot layout thrashing and setInterval misuse, and replace them with clean requestAnimationFrame loops with correct frame budget management.
Performance audit
Identify layout thrashing and dropped frames in DevTools
rAF refactoring
Replace setTimeout animations with correct rAF loops
Canvas & WebGL
High-performance render loops for data-intensive visualizations
requestAnimationFrame: the essentials at a glance
Render synchronization
rAF runs before the browser's render step, so callbacks see the next frame. Automatically pauses on invisible tabs and adapts to the display frequency.
Frame-rate independence
Use the DOMHighResTimeStamp delta: compute motion in px/ms, not px/frame. That way the animation runs at the same speed on 60 Hz and 120 Hz.
Preventing layout thrashing
All DOM reads first, then all DOM writes. Prefer transform and opacity, both run on the compositor thread without triggering layout.
Clean cleanup
Store the handle, call cancelAnimationFrame in cleanup. Without cancel: memory leaks and wasted CPU in component architectures.
11. FAQ: requestAnimationFrame
1What does requestAnimationFrame do differently from setTimeout?
2How do I stop a rAF loop?
rafId = requestAnimationFrame(fn), then cancelAnimationFrame(rafId). Without a handle, a clean stop is not possible.3What is layout thrashing?
4How do you make animations frame-rate-independent?
delta = timestamp - lastTimestamp. Define motion in px/ms instead of px/frame, same speed on 60 Hz and 120 Hz.