JavaScript performance optimization follows the rule "measure first, then optimize." Firefox's Performance tab gives you a precise flame graph of every millisecond spent in your code. This guide covers the common bottlenecks and how to fix them.
Measurement First
// Performance API: precise timing
const start = performance.now();
expensiveOperation();
console.log(`Took ${performance.now() - start}ms`);
// Mark and measure
performance.mark('operation-start');
expensiveOperation();
performance.mark('operation-end');
performance.measure('operation', 'operation-start', 'operation-end');
const [entry] = performance.getEntriesByName('operation');
console.log(entry.duration, 'ms');
Firefox Performance Tab
- Open DevTools (F12) → Performance
- Click Record (or Ctrl+Shift+E)
- Perform the slow operation
- Click Stop
- Analyze the flame graph
What to look for:
- Long tasks (shown in red): tasks > 50ms block the main thread
- Forced reflows: JavaScript reads layout properties after DOM mutations
- Render blocking: long paint or layout calculations
- GC pauses: frequent garbage collection
The Bottlenecks
Forced Layout (Thrashing)
// BAD: forces layout calculation between each read/write
for (const el of elements) {
el.style.width = el.offsetWidth + 10 + 'px'; // Read then write — forces reflow
}
// GOOD: batch reads, then writes
const widths = elements.map(el => el.offsetWidth); // Read all
elements.forEach((el, i) => el.style.width = widths[i] + 10 + 'px'); // Write all
Properties that trigger layout: offsetWidth, offsetHeight, getBoundingClientRect(), scrollTop, clientWidth, getComputedStyle().
DOM Manipulation
// BAD: each append forces layout recalculation
const list = document.querySelector('ul');
for (const item of data) {
const li = document.createElement('li');
li.textContent = item;
list.appendChild(li); // Triggers layout each time
}
// GOOD: DocumentFragment or innerHTML
const fragment = document.createDocumentFragment();
for (const item of data) {
const li = document.createElement('li');
li.textContent = item;
fragment.appendChild(li);
}
list.appendChild(fragment); // Single DOM operation
// Even faster for static content: innerHTML (no individual element creation)
list.innerHTML = data.map(item => `<li>${escapeHtml(item)}</li>`).join('');
Long Synchronous Operations
// BAD: sorts a huge array on the main thread, blocking UI
function sortLargeArray(arr) {
return arr.sort((a, b) => a - b); // Blocks for seconds on large arrays
}
// GOOD: offload to a Web Worker
const worker = new Worker(URL.createObjectURL(new Blob([`
self.onmessage = (e) => {
const sorted = e.data.sort((a, b) => a - b);
self.postMessage(sorted);
};
`], { type: 'text/javascript' })));
async function sortLargeArray(arr) {
return new Promise((resolve) => {
worker.postMessage(arr, [arr.buffer]); // Transfer, don't copy
worker.onmessage = (e) => resolve(e.data);
});
}
Memory Leaks
// Common leak: event listener not removed
class Component {
constructor() {
// Store reference for cleanup
this.handleClick = this.handleClick.bind(this);
document.addEventListener('click', this.handleClick);
}
destroy() {
document.removeEventListener('click', this.handleClick); // Cleanup!
}
}
// Common leak: closure holds reference
function createLeak() {
const bigData = new Array(1000000).fill('data');
return {
doSomething: () => {
// bigData is referenced here — never GC'd as long as this object exists
console.log(bigData[0]);
}
};
}
// Fix: only keep what you need
function noLeak() {
const firstItem = new Array(1000000).fill('data')[0]; // Only keep first item
return {
doSomething: () => console.log(firstItem)
};
}
Debounce and Throttle
// Debounce: execute after N ms of no calls (search input, resize)
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// Throttle: execute at most once per N ms (scroll handlers, mouse moves)
function throttle(fn, interval) {
let lastCall = 0;
return function(...args) {
const now = Date.now();
if (now - lastCall >= interval) {
lastCall = now;
return fn.apply(this, args);
}
};
}
// Usage
const searchInput = document.querySelector('#search');
searchInput.addEventListener('input', debounce(async (e) => {
const results = await search(e.target.value);
display(results);
}, 300));
window.addEventListener('scroll', throttle(updateScrollProgress, 100));
Virtualization for Long Lists
Rendering thousands of DOM nodes is slow. Only render what's visible:
class VirtualList {
constructor(container, items, itemHeight) {
this.container = container;
this.items = items;
this.itemHeight = itemHeight;
this.render();
this.container.addEventListener('scroll', () => this.render());
}
render() {
const scrollTop = this.container.scrollTop;
const containerHeight = this.container.clientHeight;
const startIndex = Math.floor(scrollTop / this.itemHeight);
const endIndex = Math.min(
startIndex + Math.ceil(containerHeight / this.itemHeight) + 1,
this.items.length
);
this.container.innerHTML = `
<div style="height: ${this.items.length * this.itemHeight}px; position: relative;">
${this.items.slice(startIndex, endIndex).map((item, i) => `
<div style="position: absolute; top: ${(startIndex + i) * this.itemHeight}px; height: ${this.itemHeight}px;">
${item}
</div>
`).join('')}
</div>
`;
}
}
Production implementations use IntersectionObserver and more sophisticated recycling. Libraries: react-virtual, vue-virtual-scroller, @tanstack/virtual.
requestIdleCallback for Non-Urgent Work
// Schedule work during browser idle periods
function processInBackground(items, processor) {
let index = 0;
function doWork(deadline) {
while (index < items.length && deadline.timeRemaining() > 0) {
processor(items[index++]);
}
if (index < items.length) {
requestIdleCallback(doWork); // More work remaining
}
}
requestIdleCallback(doWork);
}
// Analytics, cleanup, prefetching — things that shouldn't block the user
processInBackground(analyticsEvents, sendToAnalytics);
Memoization
Cache expensive function results:
function memoize(fn) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
const expensiveCalc = memoize((n) => {
// Expensive calculation...
return result;
});
expensiveCalc(42); // Computed
expensiveCalc(42); // Returned from cache instantly
Using Firefox to Find the Actual Bottleneck
Profile your code in Firefox before optimizing:
- Record a performance trace during the slow operation
- Look at the bottom-up view to find functions consuming the most time
- Check for "Forced Reflow" markers in the timeline
- Look for GC (garbage collection) pauses in purple
Only optimize what the profiler shows is slow. Premature optimization is the root of much wasted engineering time.
Top comments (0)