Runtime Performance
Loading performance gets users to the page. Runtime performance keeps the experience smooth once they are there. Janky scrolling, unresponsive buttons, and growing memory usage all stem from runtime problems.
The Main Thread
JavaScript runs on the main thread along with rendering, layout, and painting. When JavaScript runs for too long, everything else waits.
Long Tasks
A long task is any task that takes more than 50ms. During a long task, the browser cannot respond to user input or paint new frames.
// This is a long task: it blocks the main thread for ~500ms
function processLargeArray(data) {
return data.map((item) => expensiveTransform(item));
}
// Fix: break it into smaller tasks
async function processLargeArrayAsync(data) {
const results = [];
const chunkSize = 100;
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
results.push(...chunk.map((item) => expensiveTransform(item)));
// Yield to the main thread
await new Promise((resolve) => setTimeout(resolve, 0));
}
return results;
}
requestAnimationFrame
For smooth 60fps animations, use requestAnimationFrame (rAF). It syncs your code with the browser's paint cycle, running once per frame (~16.6ms at 60fps).
// Bad: setTimeout for animation
function animateBad(element) {
let position = 0;
setInterval(() => {
position += 2;
element.style.transform = `translateX(${position}px)`;
}, 16); // Not synced with display refresh
}
// Good: requestAnimationFrame
function animateGood(element) {
let position = 0;
function step() {
position += 2;
element.style.transform = `translateX(${position}px)`;
if (position < 500) {
requestAnimationFrame(step);
}
}
requestAnimationFrame(step);
}
When to Use CSS Instead
If the animation can be expressed in CSS, prefer CSS. The browser can optimize CSS animations on the compositor thread, avoiding main thread work entirely.
/* Compositor-friendly: transform and opacity */
.slide-in {
transition: transform 0.3s ease, opacity 0.3s ease;
}
/* Forces layout recalculation: avoid animating these */
.bad-animation {
transition: width 0.3s, height 0.3s, top 0.3s, left 0.3s;
}
Debouncing & Throttling
Events like scroll, resize, and input can fire dozens of times per second. Running expensive handlers on every event kills performance.
Debouncing
Wait until the user stops acting, then run once.
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// Search only after the user stops typing for 300ms
const searchInput = document.getElementById('search');
searchInput.addEventListener('input', debounce((event) => {
fetchSearchResults(event.target.value);
}, 300));
Throttling
Run at most once per interval, no matter how often the event fires.
function throttle(fn, limit) {
let lastRun = 0;
return function (...args) {
const now = Date.now();
if (now - lastRun >= limit) {
lastRun = now;
fn.apply(this, args);
}
};
}
// Update scroll position indicator at most every 100ms
window.addEventListener('scroll', throttle(() => {
updateScrollIndicator();
}, 100));
When to Use Which
| Technique | Use Case |
|---|---|
| Debounce | Search input, form validation, resize handler |
| Throttle | Scroll position, mouse move tracking, drag events |
Virtual Scrolling
Rendering 10,000 DOM nodes at once is slow. Virtual scrolling renders only the visible items, plus a small buffer.
class VirtualList {
constructor(container, items, itemHeight) {
this.container = container;
this.items = items;
this.itemHeight = itemHeight;
this.visibleCount = Math.ceil(container.clientHeight / itemHeight);
this.container.style.overflow = 'auto';
this.content = document.createElement('div');
this.content.style.height = `${items.length * itemHeight}px`;
this.container.appendChild(this.content);
this.container.addEventListener('scroll', () => this.render());
this.render();
}
render() {
const scrollTop = this.container.scrollTop;
const startIndex = Math.floor(scrollTop / this.itemHeight);
const endIndex = Math.min(startIndex + this.visibleCount + 2, this.items.length);
this.content.innerHTML = '';
for (let i = startIndex; i < endIndex; i++) {
const el = document.createElement('div');
el.textContent = this.items[i];
el.style.position = 'absolute';
el.style.top = `${i * this.itemHeight}px`;
el.style.height = `${this.itemHeight}px`;
this.content.appendChild(el);
}
}
}
In practice, use a library like @tanstack/virtual or react-window. The concept is the same: render only what is visible.
Web Workers for CPU Work
Move heavy computation off the main thread entirely.
// main.js
const worker = new Worker('sort-worker.js');
worker.postMessage({ data: millionRecords });
worker.onmessage = (event) => {
renderTable(event.data.sorted);
};
// sort-worker.js
self.onmessage = (event) => {
const sorted = event.data.data.sort((a, b) => a.score - b.score);
self.postMessage({ sorted });
};
Good Candidates for Workers
- Sorting or filtering large datasets
- Image processing (resizing, filters)
- Data parsing (CSV, JSON of large files)
- Compression / decompression
- Cryptographic operations
Memory Leaks
Memory leaks happen when the application holds references to objects it no longer needs. The garbage collector cannot free them.
Detached DOM Nodes
// Leak: element removed from DOM but still referenced
let detachedElement = document.createElement('div');
document.body.appendChild(detachedElement);
document.body.removeChild(detachedElement);
// detachedElement still holds the node in memory
// Fix: remove the reference
detachedElement = null;
Forgotten Event Listeners
// Leak: listener keeps the handler (and its closure) alive
function setupHandler() {
const largeData = new Array(1000000).fill('data');
window.addEventListener('resize', () => {
console.log(largeData.length); // Closure holds largeData
});
}
// Fix: store a reference and remove when done
function setupHandlerFixed() {
const largeData = new Array(1000000).fill('data');
function handler() {
console.log(largeData.length);
}
window.addEventListener('resize', handler);
// Clean up when component unmounts
return () => window.removeEventListener('resize', handler);
}
Timers
// Leak: interval runs forever, holding references
const intervalId = setInterval(() => {
updateWidget(heavyData);
}, 1000);
// Fix: clear when no longer needed
clearInterval(intervalId);
Profiling with Chrome DevTools
Performance Tab
- Open DevTools > Performance
- Click Record
- Interact with the page (scroll, click, type)
- Stop recording
- Analyze the flame chart
What to Look For
- Long tasks: yellow bars longer than 50ms in the main thread
- Layout thrashing: purple layout events triggered repeatedly
- Forced reflow: JavaScript reads layout properties after writes
Memory Tab
- Open DevTools > Memory
- Take a Heap Snapshot
- Interact with the page
- Take another snapshot
- Compare to find objects that should have been freed
Common Pitfalls
- Animating layout properties: animating
width,height,top, orlefttriggers layout recalculation every frame. Usetransformandopacityinstead. - Not cleaning up event listeners: listeners on
windowordocumentpersist across component lifecycles in SPAs. Always remove them. - Using setInterval for animations: setInterval does not sync with the browser's paint cycle. Use
requestAnimationFramefor visual updates. - Rendering the entire list: DOM nodes are expensive. Virtual scrolling can reduce thousands of nodes to dozens.
- Ignoring memory until it crashes: memory leaks are silent. Profile periodically during development.
- Forced synchronous layout: reading a layout property (offsetHeight, getBoundingClientRect) after modifying styles forces an immediate layout recalculation.
Key Takeaways
- Long tasks (over 50ms) block the main thread and make the page feel unresponsive
- Use
requestAnimationFramefor visual updates and CSS transitions/transforms for compositor-friendly animations - Debounce for "wait until done" (search input) and throttle for "run at most N times per second" (scroll)
- Virtual scrolling renders only visible items, turning a 10,000-node list into a 20-node list
- Web Workers move CPU-intensive work off the main thread entirely
- Memory leaks come from detached DOM nodes, forgotten event listeners, uncapped caches, and uncleared timers
- Use Chrome DevTools Performance tab to find long tasks and the Memory tab to find leaks