Measuring input latency in rAF loops
Timing the path from pointer event to pixels, and isolating where the felt difference comes from.
“It feels sluggish” is usually a matter of tens of milliseconds. So I measured where those milliseconds actually go.
Deciding what to measure
Between pressing a pointer and the screen changing, there are several stages.
- Physical input until the browser produces an event
- The handler firing until the next
requestAnimationFrame - The drawing itself
- Draw completion until pixels actually reach the display
Only 2 and 3 are visible from JS. Stages 1 and 4 belong to the browser and the display, and you cannot touch them. Stage 2, however, depends heavily on your design.
From event to next frame
An event’s timeStamp shares a time base with the rAF callback argument, so you can subtract directly.
canvas.addEventListener('pointerdown', (e) => {
const evtTime = e.timeStamp;
requestAnimationFrame((frameTime) => {
console.log('event → frame:', (frameTime - evtTime).toFixed(1), 'ms');
});
});
Locally this ranged from 1ms to 16ms, which makes sense — it depends where in the frame the event arrived. At 60Hz that averages about 8ms, worst case 16ms.
In other words, while you draw in rAF, roughly 8ms of average latency is unavoidable. Removing it means a 120Hz display; there is nothing to do about it in code.
What can be cut
Two things actually moved the needle.
Draw without waiting on state. Holding state in React’s setState means the frame goes
through a re-render before it draws, which can add a frame. The click feedback ring is not
state — it goes into an array the render loop reads directly.
// straight into the array the render loop reads, not into state
hits.push({ x, y, t: now });
Things like score, where a frame of lag is invisible, are fine as state. Only the effects you want at the moment of the press need this treatment.
Use pointerdown. click does not fire until the button is released. If you want a
response on press, listen for pointerdown. That alone removes the entire press-to-release
duration, which for some people is over 50ms.
Results
| Change | Time saved |
|---|---|
click → pointerdown |
40–80ms |
| Feedback out of state | 8–16ms |
| The rAF wait | not removable (~8ms avg) |
An anticlimactic conclusion: swapping the event type dwarfs everything else. Look at how you take input before you start optimising the render loop.