The 60fps Revelation: How requestAnimationFrame Saved My Drag-and-Drop Dreams
Learn how requestAnimationFrame can fix laggy drag-and-drop UIs, boost performance, and create buttery-smooth user experiences in React apps

Picture this: You're dragging a component across your screen in what should be a buttery-smooth visual editor. Instead, it feels like you're dragging a refrigerator through molasses while someone plays a slideshow of where your cursor might be. Your beautiful drag-and-drop interface has turned into a laggy nightmare that makes users question their life choices.
This was my reality while building Vade AI's no-code platform. I tried every drag-and-drop library on the planet, forked half of them, and even considered switching careers to goat farming. Then I discovered requestAnimationFrame, and everything changed.
Here's the story of how a single browser API transformed my janky interface into the smooth, zen-like experience I'd been chasing for months.
The Demo: See the Difference Yourself
Before we dive into the theory, let's see the problem in action. Try dragging the boxes below - the difference will make you a believer:
import React, { useState, useEffect, useRef, useCallback } from 'react'
// The Wrong Way: Using setTimeout
function SetTimeoutBox() {
const [position, setPosition] = useState({ x: 50, y: 50 })
const [isDragging, setIsDragging] = useState(false)
const timeoutRef = useRef(null)
const dragDataRef = useRef({ startX: 0, startY: 0, offsetX: 0, offsetY: 0 })
const startDrag = useCallback((e) => {
const rect = e.currentTarget.getBoundingClientRect()
dragDataRef.current = {
startX: e.clientX,
startY: e.clientY,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top
}
setIsDragging(true)
}, [])
const updatePosition = useCallback((clientX, clientY) => {
const newX = clientX - dragDataRef.current.offsetX
const newY = clientY - dragDataRef.current.offsetY
// Clear any pending timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
// The Wrong Way: setTimeout creates inconsistent timing
timeoutRef.current = setTimeout(() => {
setPosition({ x: Math.max(0, newX), y: Math.max(0, newY) })
}, 16) // Trying to simulate 60fps, but it's not synced!
}, [])
useEffect(() => {
if (!isDragging) return
const handleMouseMove = (e) => {
updatePosition(e.clientX, e.clientY)
}
const handleMouseUp = () => {
setIsDragging(false)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [isDragging, updatePosition])
return (
<div className="relative w-full h-64 bg-red-50 border border-red-200 rounded-lg overflow-hidden">
<div className="absolute top-2 left-2 text-red-600 font-semibold">
❌ setTimeout Method (Janky)
</div>
<div
className="absolute w-16 h-16 bg-red-500 rounded cursor-move shadow-lg transition-shadow hover:shadow-xl"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
userSelect: 'none'
}}
onMouseDown={startDrag}
>
<div className="flex items-center justify-center h-full text-white font-bold">
📦
</div>
</div>
</div>
)
}
// The Right Way: Using requestAnimationFrame
function RAFBox() {
const [position, setPosition] = useState({ x: 50, y: 50 })
const [isDragging, setIsDragging] = useState(false)
const rafRef = useRef(null)
const dragDataRef = useRef({ startX: 0, startY: 0, offsetX: 0, offsetY: 0 })
const pendingUpdateRef = useRef(null)
const startDrag = useCallback((e) => {
const rect = e.currentTarget.getBoundingClientRect()
dragDataRef.current = {
startX: e.clientX,
startY: e.clientY,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top
}
setIsDragging(true)
}, [])
const scheduleUpdate = useCallback((clientX, clientY) => {
// Store the latest position
pendingUpdateRef.current = {
x: clientX - dragDataRef.current.offsetX,
y: clientY - dragDataRef.current.offsetY
}
// Cancel any pending frame
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
// The Right Way: requestAnimationFrame syncs with display refresh
rafRef.current = requestAnimationFrame(() => {
if (pendingUpdateRef.current) {
const { x, y } = pendingUpdateRef.current
setPosition({
x: Math.max(0, x),
y: Math.max(0, y)
})
pendingUpdateRef.current = null
}
rafRef.current = null
})
}, [])
useEffect(() => {
if (!isDragging) return
const handleMouseMove = (e) => {
scheduleUpdate(e.clientX, e.clientY)
}
const handleMouseUp = () => {
setIsDragging(false)
// Critical: Cancel any pending animation frame
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
// Always cleanup on unmount
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
}, [isDragging, scheduleUpdate])
return (
<div className="relative w-full h-64 bg-green-50 border border-green-200 rounded-lg overflow-hidden">
<div className="absolute top-2 left-2 text-green-600 font-semibold">
✅ requestAnimationFrame (Smooth)
</div>
<div
className="absolute w-16 h-16 bg-green-500 rounded cursor-move shadow-lg transition-shadow hover:shadow-xl"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
userSelect: 'none'
}}
onMouseDown={startDrag}
>
<div className="flex items-center justify-center h-full text-white font-bold">
📦
</div>
</div>
</div>
)
}
// Performance Monitor Component
function PerformanceMonitor() {
const [stats, setStats] = useState({
setTimeoutFrames: 0,
rafFrames: 0,
setTimeoutFPS: 0,
rafFPS: 0
})
const startTimeRef = useRef(Date.now())
const setTimeoutCountRef = useRef(0)
const rafCountRef = useRef(0)
useEffect(() => {
const interval = setInterval(() => {
const elapsed = (Date.now() - startTimeRef.current) / 1000
setStats({
setTimeoutFrames: setTimeoutCountRef.current,
rafFrames: rafCountRef.current,
setTimeoutFPS: Math.round(setTimeoutCountRef.current / elapsed),
rafFPS: Math.round(rafCountRef.current / elapsed)
})
}, 100)
return () => clearInterval(interval)
}, [])
return (
<div className="bg-gray-100 p-4 rounded-lg">
<h3 className="font-semibold mb-2">📊 Performance Monitor</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-red-600">setTimeout Method:</div>
<div>Frames: {stats.setTimeoutFrames}</div>
<div>FPS: {stats.setTimeoutFPS}</div>
</div>
<div>
<div className="text-green-600">RAF Method:</div>
<div>Frames: {stats.rafFrames}</div>
<div>FPS: {stats.rafFPS}</div>
</div>
</div>
</div>
)
}
// Main Demo Component
export default function RAFDemo() {
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold mb-2">🎯 Interactive Demo</h2>
<p className="text-gray-600">
Drag both boxes and feel the difference. The top one uses setTimeout,
the bottom uses requestAnimationFrame.
</p>
</div>
<SetTimeoutBox />
<RAFBox />
<PerformanceMonitor />
<div className="bg-blue-50 p-4 rounded-lg">
<h3 className="font-semibold text-blue-800 mb-2">🔍 What to Notice:</h3>
<ul className="text-blue-700 space-y-1 text-sm">
<li>• The setTimeout box feels stuttery and inconsistent</li>
<li>• The RAF box follows your cursor smoothly</li>
<li>• Move fast - the difference becomes more obvious</li>
<li>• Check the performance monitor for frame rate differences</li>
</ul>
</div>
</div>
)
}
The Problem: When Fast Becomes Too Fast

The demo above shows the core issue. When you use setTimeout(fn, 16)
for animations, you're essentially saying:
"Hey browser, run this sometime around 16ms from now, whenever you feel like it."
But your display has a refresh rate (usually 60Hz), which means it updates exactly every 16.67 milliseconds. The mismatch creates stuttering because:
// What you think happens with setTimeout:
// 0ms: update | 16ms: update | 32ms: update | 48ms: update
// What actually happens:
// 0ms: update | 18ms: update | 35ms: update | 52ms: update
// Display refreshes at: 0ms, 16.67ms, 33.33ms, 50ms
// Result: Out of sync = stuttering
The key insight: Your display doesn't care about your JavaScript timing.
The Solution: Sync with the Display
requestAnimationFrame
solves this by saying:
"Call me back right before the next display refresh, when you're actually going to show something to the user."
Looking at our RAF implementation:
const scheduleUpdate = useCallback((clientX, clientY) => {
// Store the latest mouse position
pendingUpdateRef.current = {
x: clientX - dragDataRef.current.offsetX,
y: clientY - dragDataRef.current.offsetY
}
// Cancel any pending frame (crucial!)
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
// Schedule update for next display refresh
rafRef.current = requestAnimationFrame(() => {
if (pendingUpdateRef.current) {
const { x, y } = pendingUpdateRef.current
setPosition({ x: Math.max(0, x), y: Math.max(0, y) })
pendingUpdateRef.current = null
}
rafRef.current = null
})
}, [])
This pattern does three critical things:
- Cancels outdated frames - If mouse events come faster than 60fps, we only use the latest position
- Syncs with display - Updates happen exactly when the browser repaints
- Avoids frame pile-up - Never schedules multiple RAF callbacks
The Memory Management: cancel Animation Frame Is Your Friend

Here's where many developers mess up. Every requestAnimationFrame
returns an ID that you must clean up:
useEffect(() => {
if (!isDragging) return
const handleMouseMove = (e) => {
scheduleUpdate(e.clientX, e.clientY)
}
const handleMouseUp = () => {
setIsDragging(false)
// 🧹 Critical: Cancel pending animations
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
// 🧹 Always cleanup on unmount
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
}, [isDragging, scheduleUpdate])
Why this matters:
- Prevents memory leaks
- Stops unnecessary computations
- Avoids React state updates on unmounted components
The Advanced Pattern: High-Frequency Updates with RAF
In real drag-and-drop systems, you often need to update multiple things per frame. Here's the pattern I use in Vade AI:
function useSmootDragUpdater() {
const rafRef = useRef(null)
const pendingUpdatesRef = useRef({})
const scheduleUpdate = useCallback((updateKey, updateFn) => {
// Batch multiple updates for the same frame
pendingUpdatesRef.current[updateKey] = updateFn
if (rafRef.current) return // Already scheduled
rafRef.current = requestAnimationFrame(() => {
// Execute all pending updates in one frame
Object.values(pendingUpdatesRef.current).forEach(fn => fn())
pendingUpdatesRef.current = {}
rafRef.current = null
})
}, [])
const cancelUpdates = useCallback(() => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
pendingUpdatesRef.current = {}
}, [])
useEffect(() => {
return () => cancelUpdates() // Cleanup on unmount
}, [cancelUpdates])
return { scheduleUpdate, cancelUpdates }
}
// Usage in a complex drag system
function ComplexDragComponent() {
const [ghostPosition, setGhostPosition] = useState({ x: 0, y: 0 })
const [dropZones, setDropZones] = useState([])
const [selection, setSelection] = useState(null)
const { scheduleUpdate, cancelUpdates } = useSmootDragUpdater()
const handleDrag = useCallback((e) => {
// Schedule all updates for the same frame
scheduleUpdate('ghost', () => {
setGhostPosition({ x: e.clientX, y: e.clientY })
})
scheduleUpdate('dropZones', () => {
setDropZones(calculateDropZones(e.target))
})
scheduleUpdate('selection', () => {
setSelection(findSelectionTarget(e.target))
})
}, [scheduleUpdate])
const handleDragEnd = useCallback(() => {
cancelUpdates() // Clean stop
}, [cancelUpdates])
// ... rest of component
}
This pattern ensures all related updates happen in the same frame, creating a cohesive visual experience.
The Performance Reality: Numbers That Matter
After implementing RAF throughout Vade AI's interface, here's what actually changed:
Before RAF (setTimeout approach):
// Inconsistent frame times
// 12ms, 28ms, 15ms, 31ms, 19ms...
// Dropped frames: 15-20% during fast drags
// Users: "It feels laggy"
After RAF:
// Consistent frame times
// 16.67ms, 16.67ms, 16.67ms, 16.67ms...
// Dropped frames: <1%
// Users: "It feels like magic"
The difference isn't just technical—it's emotional. Smooth interactions create trust. Janky ones create frustration.
The React Hooks Pattern: Making RAF Reusable
Here's the reusable hook pattern I've settled on:
function useAnimationFrame(callback, dependencies = []) {
const rafRef = useRef(null)
const callbackRef = useRef(callback)
// Keep callback ref updated
useEffect(() => {
callbackRef.current = callback
})
const start = useCallback(() => {
if (rafRef.current) return // Already running
const animate = () => {
callbackRef.current()
rafRef.current = requestAnimationFrame(animate)
}
rafRef.current = requestAnimationFrame(animate)
}, [])
const stop = useCallback(() => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}, [])
// Auto-cleanup
useEffect(() => {
return () => stop()
}, [stop])
return { start, stop }
}
// Usage for continuous animations
function AnimatedComponent() {
const [rotation, setRotation] = useState(0)
const { start, stop } = useAnimationFrame(() => {
setRotation(prev => (prev + 1) % 360)
})
return (
<div
style={{ transform: `rotate(${rotation}deg)` }}
onMouseEnter={start}
onMouseLeave={stop}
>
🎡 Spinning Wheel
</div>
)
}
The Common Pitfalls: Learn from My Mistakes

Pitfall 1: Forgetting Cleanup
// ❌ Wrong: Memory leak waiting to happen
function BadComponent() {
useEffect(() => {
const animate = () => {
updateSomething()
requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
// Missing cleanup!
}, [])
}
// ✅ Right: Always cleanup
function GoodComponent() {
useEffect(() => {
let rafId = null
const animate = () => {
updateSomething()
rafId = requestAnimationFrame(animate)
}
rafId = requestAnimationFrame(animate)
return () => {
if (rafId) cancelAnimationFrame(rafId)
}
}, [])
}
Pitfall 2: Heavy Computations in RAF
// ❌ Wrong: Blocks the main thread
rafRef.current = requestAnimationFrame(() => {
const result = heavyComputation() // Takes 50ms!
updateUI(result)
})
// ✅ Right: Compute async, render in RAF
useEffect(() => {
const worker = new Worker('heavy-computation.js')
worker.onmessage = (e) => {
rafRef.current = requestAnimationFrame(() => {
updateUI(e.data) // Light rendering only
})
}
worker.postMessage(data)
}, [data])
The Zen State: When Performance Becomes Invisible

The ultimate goal isn't just smoother animations—it's creating that magical flow state where the interface disappears and users can focus purely on their creativity.
When someone is building their website at 2 AM and hits that perfect flow state, when drag-and-drop feels effortless, when every interaction responds exactly as expected—that's when real work gets done. That's when ideas become reality.
requestAnimationFrame isn't just about performance; it's about respecting the user's intent and removing friction from the creative process.
Your Action Plan: From Janky to Smooth
- Replace setTimeout animations with the RAF patterns above
- Always use cancelAnimationFrame in cleanup functions
- Batch updates that happen in the same frame
- Test on different refresh rates - your app should work on 60Hz, 120Hz, and 144Hz displays
- Profile with React DevTools - look for unnecessary re-renders
- Use the demo above to convince your team (and yourself) that it matters
The difference between laggy and smooth isn't just technical—it's emotional. It's the difference between frustration and flow, between abandoning a task and achieving greatness.
Try the demo again. Feel the difference. That smooth, responsive experience? That's what your users deserve.
"The best interface is the one you don't notice. requestAnimationFrame makes that possible, one smooth frame at a time."