I remember the first animation I ever built with JavaScript — a setInterval loop nudging a div‘s left position by a few pixels every 50 milliseconds. It was janky, it stuttered, and it taught me almost nothing about how animation actually works in a browser. Since then, I’ve built everything from simple hover effects to complex canvas-based data visualizations, and I want to share the techniques, internals, and hard-won lessons that took me from that first stuttering box to smooth, professional animations.
Why Some Animations Feel Smooth and Others Don’t
The number that matters most in web animation is 60 frames per second — roughly one frame every 16.67 milliseconds. If your JavaScript takes longer than that to update the animation state and the browser to paint it, you get dropped frames, which the human eye perceives as jank. Understanding this budget is the foundation of everything else in this article.
The Wrong Way: setInterval and setTimeout
Early web animation almost always used setInterval:
let position = 0;
const box = document.getElementById('box');
setInterval(() => {
position += 2;
box.style.left = position + 'px';
}, 16);
The problem is that setInterval doesn’t know anything about the browser’s actual rendering schedule. It fires based on a fixed timer, regardless of whether the browser is ready to paint, whether the tab is in the background, or whether the previous callback is still running. This causes stutter and wasted CPU cycles.
The Right Way: requestAnimationFrame
requestAnimationFrame (rAF) tells the browser “call this function right before the next repaint.” It automatically syncs with the display’s refresh rate and pauses when the tab isn’t visible, saving battery and CPU.
let position = 0;
const box = document.getElementById('box');
function animate() {
position += 2;
box.style.left = position + 'px';
if (position < 300) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
This is the foundation of essentially every JavaScript animation library I’ve ever used or built, from simple sliders to complex physics engines.
Time-Based Animation, Not Frame-Based
A mistake I made for years: incrementing position by a fixed amount per frame, which means animation speed depends on the device’s refresh rate. A 120Hz display would run my animation twice as fast as a 60Hz one. The fix is to base movement on elapsed time, not frame count:
let start = null;
const duration = 1000; // 1 second
const box = document.getElementById('box');
function animate(timestamp) {
if (!start) start = timestamp;
const elapsed = timestamp - start;
const progress = Math.min(elapsed / duration, 1);
box.style.transform = `translateX(${progress * 300}px)`;
if (progress < 1) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
Note that requestAnimationFrame passes a high-resolution timestamp to the callback automatically — no need to call Date.now() yourself.
Easing Functions
Linear motion looks robotic. Real-world motion accelerates and decelerates, which is where easing functions come in:
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
function animate(timestamp) {
if (!start) start = timestamp;
const elapsed = timestamp - start;
const progress = Math.min(elapsed / duration, 1);
const eased = easeOutCubic(progress);
box.style.transform = `translateX(${eased * 300}px)`;
if (progress < 1) requestAnimationFrame(animate);
}
I keep a small library of easing functions (ease-in, ease-out, ease-in-out, elastic, bounce) that I reuse across projects, because getting the “feel” right is 80% of what makes animation look professional.
CSS Transitions and Animations vs. JavaScript
Not every animation needs JavaScript. For simple state-based transitions, I default to CSS first:
.box {
transition: transform 0.3s ease-out;
}
.box.active {
transform: translateX(300px);
}
box.classList.add('active');
CSS transitions and animations can run on the browser’s compositor thread, separate from the main JavaScript thread, when animating compositor-friendly properties like transform and opacity. This means they keep running smoothly even if the main thread is briefly busy — something raw JavaScript-driven animation of properties like left or width cannot do, since those trigger layout recalculation on the main thread.
Why transform and opacity Are Special
This is one of the most important performance lessons in web animation. The browser rendering pipeline goes through these stages:
- Style — calculate which CSS rules apply.
- Layout — compute geometry (position, size) — expensive.
- Paint — rasterize pixels into layers — expensive.
- Composite — combine layers on the GPU — cheap.
Animating left, top, width, or height triggers layout on every frame. Animating transform and opacity can skip layout and paint entirely, going straight to the cheap compositing step. This is why I animate transform: translateX() instead of left whenever I can.
// Expensive - triggers layout every frame
box.style.left = position + 'px';
// Cheap - compositor-only property
box.style.transform = `translateX(${position}px)`;
Canvas-Based Animation
For more complex visuals — particle systems, data visualizations, games — I move to the <canvas> element, which gives me direct pixel-level control:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let x = 0;
function draw(timestamp) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#3366ff';
ctx.beginPath();
ctx.arc(x, 100, 20, 0, Math.PI * 2);
ctx.fill();
x = (x + 2) % canvas.width;
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
Clearing and redrawing the entire canvas every frame is standard practice — trying to selectively erase parts of a canvas almost always ends up more complex and slower than a full clear-and-redraw cycle.
The Web Animations API
For programmatic animations that still benefit from browser-native optimization, I often use the Web Animations API (WAAPI), which gives me JavaScript control with the performance characteristics of CSS animations:
box.animate([
{ transform: 'translateX(0)' },
{ transform: 'translateX(300px)' }
], {
duration: 1000,
easing: 'ease-out',
fill: 'forwards',
});
I like WAAPI because it returns an Animation object I can pause, reverse, or chain:
const animation = box.animate(/* ... */);
animation.pause();
animation.playbackRate = 2;
animation.play();
Performance and Memory Considerations
- Avoid creating new objects or functions inside the animation loop — this can trigger unnecessary garbage collection pauses that cause visible jank.
- Batch DOM reads and writes; interleaving
.offsetWidthreads with.stylewrites forces the browser to recalculate layout synchronously (called “layout thrashing”). - Use
will-change: transformsparingly to hint the browser to promote an element to its own compositor layer — but overusing it can waste GPU memory.
// Bad - causes layout thrashing
elements.forEach((el) => {
el.style.width = el.offsetWidth + 10 + 'px'; // read then write, repeated
});
// Good - batch reads, then batch writes
const widths = elements.map((el) => el.offsetWidth);
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + 'px';
});
Debugging Animation Performance
Chrome DevTools’ Performance panel is my go-to tool. I record a session while the animation runs and look for:
- Long tasks on the main thread (anything over 50ms is suspect).
- Layout/Paint events happening every frame (a sign I should switch to transform/opacity).
- Frame rate drops visible in the FPS meter.
Common Mistakes
| Mistake | Effect | Fix |
|---|---|---|
Using setInterval for animation | Stutter, wasted CPU | Use requestAnimationFrame |
| Frame-based instead of time-based movement | Speed varies by refresh rate | Base progress on elapsed timestamp |
Animating left/width | Forces layout every frame | Animate transform/opacity |
| Layout thrashing (interleaved read/write) | Janky, slow animation | Batch DOM reads, then writes |
| Not canceling rAF loops on unmount | Memory leaks in SPAs | Store and cancel the rAF id |
FAQs
Should I use a library like GSAP instead of writing my own animation code? For complex sequencing, timelines, and cross-browser edge cases, yes — GSAP handles a huge amount of nuance I’d rather not reinvent. For simple, isolated animations, vanilla requestAnimationFrame or CSS is often enough.
Why does my animation look fine on my laptop but janky on a phone? Mobile devices have less CPU/GPU headroom. Test on real mid-range devices, not just high-end laptops, and prefer compositor-friendly properties.
What’s the difference between CSS animations and the Web Animations API? WAAPI gives you the same performance characteristics as CSS animations but with full JavaScript control — pausing, reversing, dynamic timing — without needing to toggle CSS classes.
Summary and Key Takeaways
Animation in JavaScript is really a story about working with the browser’s rendering pipeline instead of against it:
- Always use
requestAnimationFrame, neversetInterval, for JavaScript-driven animation. - Base movement on elapsed time, not frame count.
- Prefer animating
transformandopacityto stay off the main thread’s layout/paint path. - Use canvas for complex, pixel-level visuals; use CSS/WAAPI for everything else.
- Profile with DevTools before assuming an animation is “as fast as it can be.”
References
- MDN — requestAnimationFrame
- MDN — Web Animations API
- MDN — CSS Transitions
- Google Web Fundamentals — Rendering Performance (referenced via MDN Performance docs)