Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Aug 25, 2026·8 min read

requestAnimationFrame & VSync: How JavaScript Syncs to the Screen

Trace the hardware signal path from monitor VSync interrupt through the OS, GPU Process, and Renderer's event loop to understand exactly when rAF fires and why setTimeout is architecturally wrong for animation.

Technical Series

Event Loop Mastery

Part 2 of 2

requestAnimationFrame & VSync: How JavaScript Syncs to the Screen

The browser does not have a hardcoded "aim for 60 fps" timer buried in its source code. Instead, it is entirely reactive: it listens for a hardware signal from your monitor and synchronises its render cycle to that signal. requestAnimationFrame is the API that lets your code participate in that signal's cadence.
Understanding the full signal path — from monitor hardware, through the OS display driver, into the GPU Process, across an IPC channel, and finally into the Renderer's event loop — removes all the mystery around why setTimeout(fn, 16) produces janky animations while requestAnimationFrame produces smooth ones.
Vertical signal flow diagram tracing the VSync path from hardware to JavaScript. At the top: a monitor icon labeled 60 Hz / 120 Hz Display emitting a hardware interrupt signal downward. Below: the OS display driver (CoreAnimation on macOS, DWM on Windows) receiving the signal. Below that: the Chrome GPU Process, which hooks into the OS API and receives the VSync tick. An IPC message arrow crosses from the GPU Process into the Renderer Process. Inside the Renderer Process, the Event Loop receives the IPC tick and fires rAF callbacks before entering the Style, Layout, Paint, Composite pipeline. The bottom of the diagram shows the completed frame being returned to the GPU Process for display.
Figure: Vertical signal flow diagram tracing the VSync path from hardware to JavaScript. At the top: a monitor icon labeled 60 Hz / 120 Hz Display emitting a hardware interrupt signal downward. Below: the OS display driver (CoreAnimation on macOS, DWM on Windows) receiving the signal. Below that: the Chrome GPU Process, which hooks into the OS API and receives the VSync tick. An IPC message arrow crosses from the GPU Process into the Renderer Process. Inside the Renderer Process, the Event Loop receives the IPC tick and fires rAF callbacks before entering the Style, Layout, Paint, Composite pipeline. The bottom of the diagram shows the completed frame being returned to the GPU Process for display.

1. The VSync Signal Path

1.1 The Monitor (The Metronome)

Your physical display panel has a fixed or variable hardware refresh rate — the number of times per second it redraws its pixel grid. On a 60 Hz display, this happens 60 times per second, every 16.67 ms.
When the panel finishes displaying the current frame and is ready to receive new pixel data, it sends a VSync interrupt — a hardware signal — to the connected GPU. This signal is the clock that drives all smooth rendering.

1.2 The Operating System Display Subsystem

The GPU's driver receives the VSync interrupt and surfaces it to userspace through a platform-specific display API:
PlatformDisplay API
macOSCore Animation / CVDisplayLink
WindowsDirectX / DWM (Desktop Window Manager)
LinuxDRM/KMS (Direct Rendering Manager)
AndroidChoreographer
The OS fires a software callback at the VSync rate, exposed to processes that have registered interest. The browser's GPU Process is one such process.

1.3 The Browser GPU Process

The GPU Process maintains a registration with the OS display API. Every time the VSync callback fires, the GPU Process performs two actions:
  1. It submits the most recently composited frame to the OS display pipeline for display.
  2. It sends an IPC message — the "VSync Tick" — to every Renderer Process that has a visible window.
Architectural Note
If you drag a browser window from a 60 Hz monitor to a 144 Hz monitor, the OS display API updates its callback frequency from 16.67 ms to 6.94 ms. The GPU Process begins sending VSync Ticks to Renderer Processes at the new rate automatically. No code change is needed. requestAnimationFrame adapts because it is tied to the VSync Tick, not to a software timer.

1.4 The Renderer Process: The Event Loop

When the Renderer Process's event loop finishes its current Macrotask and drains the Microtask queue, it checks for a pending VSync Tick:
  • Tick has arrived: Enter the render detour — fire all requestAnimationFrame callbacks, then run Style Calculation, Layout, Paint, and Composite.
  • No tick yet: Skip rendering entirely and pick up the next Macrotask from the queue.
This is the mechanism by which the browser avoids wasting CPU on rendering when no new frame is due.

2. requestAnimationFrame Is Not a Timer

requestAnimationFrame is commonly described as "like setTimeout but for animations." This framing is misleading and produces incorrect mental models.
setTimeout is driven by the browser's software clock — an internal counter that is completely decoupled from VSync. It fires approximately after the requested delay, regardless of where the monitor is in its refresh cycle.
requestAnimationFrame is not driven by a software clock at all. It is a callback queue that is drained at one specific moment in the event loop: immediately after the VSync Tick IPC message arrives and the Render Check passes. It has no "delay" parameter because there is no delay to specify — it fires exactly when the hardware says it is time to paint.

2.1 The Tearing Problem with setTimeout

javascript
// ❌ Software clock — fires whenever, regardless of VSync
let x = 0
function animate() {
  x += 5
  element.style.transform = `translateX(${x}px)`
  setTimeout(animate, 16)
}
animate()
If setTimeout fires 2 ms before a VSync tick, the Main Thread sets the transform, the Compositor reads it, and the frame is composited. But if setTimeout fires 12 ms into a 16.67 ms frame — after the Compositor has already read the previous frame's state — the mutation is too late. The browser has two choices: show the old state for this frame (a dropped frame), or delay compositing (tearing). Neither is smooth.
javascript
// ✅ VSync-synchronised — fires exactly when the hardware is ready
let x = 0
function animate(timestamp) {
  x += 5
  element.style.transform = `translateX(${x}px)`
  requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
Every call to requestAnimationFrame schedules the callback for the next VSync tick. The callback receives a high-resolution DOMHighResTimeStamp representing the start of the frame, which you can use to calculate delta time and make animations frame-rate independent.

3. Frame-Rate Independent Animation with Delta Time

A common mistake with requestAnimationFrame is tying animation speed to the callback frequency:
javascript
// ❌ Speed depends on frame rate — faster on 120 Hz than 60 Hz
requestAnimationFrame(function animate() {
  x += 5  // adds 5px per frame, not per second
  requestAnimationFrame(animate)
})

// ✅ Speed is frame-rate independent — consistent on any display
let lastTimestamp = 0
const speed = 300  // px per second

requestAnimationFrame(function animate(timestamp) {
  const deltaMs = timestamp - lastTimestamp
  lastTimestamp = timestamp

  x += speed * (deltaMs / 1000)  // converts px/s to px/frame
  element.style.transform = `translateX(${x}px)`
  requestAnimationFrame(animate)
})
On a 60 Hz display, deltaMs ≈ 16.67300 × 0.01667 = 5 px/frame. On a 120 Hz display, deltaMs ≈ 8.33300 × 0.00833 = 2.5 px/frame.
The element still travels at 300 pixels per second on both displays.

4. Background Throttling and Battery Saver Mode

When a browser tab loses focus or the window is minimised, Chromium throttles the VSync Tick to approximately 1 Hz (or stops it entirely). This means:
  • requestAnimationFrame callbacks run at most once per second in background tabs.
  • setTimeout(fn, 16) in a background tab is also throttled, but less aggressively and on a different schedule.
Pro Tip & Optimization
Use the Page Visibility API to pause requestAnimationFrame loops when a tab is hidden, to avoid queuing up animation callbacks that will fire in a burst when the tab regains focus.
typescript
let animationId: number

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    cancelAnimationFrame(animationId)
  } else {
    animationId = requestAnimationFrame(animate)
  }
})

5. The Complete Signal Timeline

Monitor hardware (60 Hz)
  │  VSync interrupt every 16.67 ms → GPU
  │
  ▼
OS display driver (CoreAnimation / DWM)
  │  VSync callback fires in userspace → Chrome GPU Process
  │
  ▼
Chrome GPU Process
  │  Submits previous frame to display
  │  Sends VSync Tick IPC → all active Renderer Processes
  │
  ▼
Renderer Process — Event Loop
  │  Finishes current Macrotask
  │  Drains Microtask queue
  │  Checks: VSync Tick received? → YES
  │  Fires all requestAnimationFrame callbacks
  │  Style Calculation
  │  Layout
  │  Paint (Display List generation)
  │  Sends composited layer data → GPU Process
  │
  ▼
GPU Process
  │  Rasterises tiles via Skia Ganesh
  │  Composites all layers
  │  Submits frame to OS at next VSync
  │
  ▼
Monitor displays new frame

6. References

  1. requestAnimationFrame — MDN Web Docs
  2. Page Visibility API — MDN Web Docs
  3. Rendering Performance: Stick to Compositor-Only Properties — Google Developers
  4. High Resolution Time — W3C
  5. Inside look at modern web browser (part 4) — Chrome Developers
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#JavaScript#requestAnimationFrame#VSync#Animation#Browser Internals#Performance#60fps
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.