Master JavaScript performance optimization techniques including DOM batching, memoization, Web Workers, debounce/throttle & memory management for fast apps.
Introduction
JavaScript performance optimization is the most critical skill in modern web development. A slow website not only degrades user experience, but also brings down SEO rankings. Google's Core Web Vitals — LCP (Largest Contentful Paint), FID (First Input Delay), and CLS (Cumulative Layout Shift) — directly affect your site's ranking.
Performance optimization doesn't just mean making code run fast — it means efficient memory usage, smooth animations, responsive UI, and minimal resource consumption. In this guide, we cover all important JavaScript performance optimization techniques with real code examples.
- User Retention: 53% users leave a page if it takes more than 3 seconds to load
- SEO Impact: Google uses Core Web Vitals as ranking signals
- Conversion Rate: Every 100ms delay reduces conversions by 7%
- Mobile Users: Slow JavaScript kills mobile experience on low-end devices
Browser Rendering Pipeline
To understand JavaScript performance, you first need to understand the browser rendering pipeline:
┌─────────────────────────────────────────────────────────────────────────┐
│ Browser Rendering Pipeline │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌────────┐ ┌───────┐ ┌───────────┐ │
│ │ Parse │──▶│ Style │──▶│ Layout │──▶│ Paint │──▶│ Composite │ │
│ │ (DOM) │ │ (CSSOM) │ │(Reflow)│ │ │ │ │ │
│ └─────────┘ └─────────┘ └────────┘ └───────┘ └───────────┘ │
│ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ │
│ Parses Calculates Calculates Renders Combines │
│ HTML/JS CSS rules exact pixels layers on │
│ into DOM for each position to screen GPU │
│ tree of elements │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Performance Impact:
┌──────────────┬──────────────────────────────────────────┐
│ Trigger │ What Gets Re-run │
├──────────────┼──────────────────────────────────────────┤
│ DOM Change │ Parse → Style → Layout → Paint → Comp. │
│ Style Change │ Style → Layout → Paint → Composite │
│ Layout Change│ Layout → Paint → Composite │
│ Paint Only │ Paint → Composite │
│ Composite │ Composite only (CHEAPEST!) │
└──────────────┴──────────────────────────────────────────┘
Key Insight: The fewer pipeline stages that re-run, the better the performance. transform and opacity changes only trigger the Composite stage — that's why they are ideal for animations.
Before optimizing performance, measuring is essential. "You can't optimize what you can't measure."
1. console.time() / console.timeEnd()
// Simple timing measurement
console.time("arrayOperation");
const arr = [];
for (let i = 0; i < 100000; i++) {
arr.push(i * 2);
}
console.timeEnd("arrayOperation");
// High-precision measurement with performance.now()
function measureExecution(fn, label) {
const start = performance.now();
fn();
const end = performance.now();
console.log(`${label}: ${(end - start).toFixed(4)}ms`);
}
// Measuring different approaches
measureExecution(() => {
let sum = 0;
for (let i = 0; i < 1000000; i++) {
sum += i;
}
}, "For loop sum");
measureExecution(() => {
const sum = Array.from({ length: 1000000 }, (_, i) => i)
.reduce((acc, val) => acc + val, 0);
}, "Array reduce sum");
For loop sum: 3.2450ms
Array reduce sum: 45.8920ms
// Using Performance Observer for real metrics
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
}
});
observer.observe({ entryTypes: ["measure"] });
performance.mark("start-process");
// Some heavy operation
const data = Array.from({ length: 50000 }, () => Math.random());
data.sort((a, b) => a - b);
performance.mark("end-process");
performance.measure("Data Processing", "start-process", "end-process");
DOM Optimization
DOM manipulation is the most expensive operation in JavaScript. Every DOM change potentially triggers reflow/repaint.
Batch Reads and Writes
Browser layout thrashing occurs when you interleave DOM reads and writes:
// ❌ BAD: Causes layout thrashing (read-write-read-write pattern)
function badLayout() {
const elements = document.querySelectorAll(".item");
elements.forEach((el) => {
const height = el.offsetHeight; // READ (forces layout)
el.style.height = height * 2 + "px"; // WRITE (invalidates layout)
// Next iteration's READ forces another layout calculation!
});
}
// ✅ GOOD: Batch reads first, then batch writes
function goodLayout() {
const elements = document.querySelectorAll(".item");
// Phase 1: Batch all READS
const heights = Array.from(elements).map((el) => el.offsetHeight);
// Phase 2: Batch all WRITES
elements.forEach((el, i) => {
el.style.height = heights[i] * 2 + "px";
});
}
// Measuring the difference
console.time("Bad Layout (thrashing)");
badLayout();
console.timeEnd("Bad Layout (thrashing)");
console.time("Good Layout (batched)");
goodLayout();
console.timeEnd("Good Layout (batched)");
Bad Layout (thrashing): 45.23ms
Good Layout (batched): 3.12ms
DocumentFragment for Bulk DOM Insertions
// ❌ BAD: Adding elements one by one (1000 reflows!)
function addItemsBad(container) {
console.time("Individual Append");
for (let i = 0; i < 1000; i++) {
const div = document.createElement("div");
div.textContent = `Item ${i}`;
div.className = "list-item";
container.appendChild(div); // Each append triggers reflow
}
console.timeEnd("Individual Append");
}
// ✅ GOOD: Using DocumentFragment (1 reflow!)
function addItemsGood(container) {
console.time("DocumentFragment Append");
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const div = document.createElement("div");
div.textContent = `Item ${i}`;
div.className = "list-item";
fragment.appendChild(div); // No reflow - fragment is off-DOM
}
container.appendChild(fragment); // Single reflow
console.timeEnd("DocumentFragment Append");
}
// Run comparison
const container = document.getElementById("app");
addItemsBad(container);
addItemsGood(container);
Individual Append: 156.78ms
DocumentFragment Append: 4.56ms
Minimize Reflows — CSS Class Approach
// ❌ BAD: Multiple style changes = multiple reflows
function animateBad(element) {
element.style.width = "200px"; // reflow
element.style.height = "200px"; // reflow
element.style.margin = "20px"; // reflow
element.style.padding = "10px"; // reflow
}
// ✅ GOOD: Single class change = single reflow
function animateGood(element) {
element.classList.add("animated-state");
// CSS: .animated-state { width: 200px; height: 200px; margin: 20px; padding: 10px; }
}
// ✅ ALSO GOOD: Using cssText for batch style updates
function animateAlsoGood(element) {
element.style.cssText = "width: 200px; height: 200px; margin: 20px; padding: 10px;";
}
console.log("Single class toggle: 1 reflow");
console.log("Individual properties: 4 reflows");
console.log("cssText batch: 1 reflow");
Single class toggle: 1 reflow
Individual properties: 4 reflows
cssText batch: 1 reflow
Loop Optimization
Small optimizations in loops make a significant difference on large datasets.
// Loop optimization techniques comparison
const hugeArray = Array.from({ length: 5000000 }, () => Math.random());
// ❌ BAD: Accessing .length every iteration
console.time("Uncached length");
let sum1 = 0;
for (let i = 0; i < hugeArray.length; i++) {
sum1 += hugeArray[i];
}
console.timeEnd("Uncached length");
// ✅ GOOD: Cache array length
console.time("Cached length");
let sum2 = 0;
for (let i = 0, len = hugeArray.length; i < len; i++) {
sum2 += hugeArray[i];
}
console.timeEnd("Cached length");
// ✅ GOOD: Reverse loop (avoids length check)
console.time("Reverse loop");
let sum3 = 0;
for (let i = hugeArray.length - 1; i >= 0; i--) {
sum3 += hugeArray[i];
}
console.timeEnd("Reverse loop");
// Avoid DOM access inside loops
console.time("DOM in loop (BAD)");
for (let i = 0; i < 1000; i++) {
document.getElementById("output").innerHTML += i + " ";
}
console.timeEnd("DOM in loop (BAD)");
console.time("Build string then DOM (GOOD)");
let result = "";
for (let i = 0; i < 1000; i++) {
result += i + " ";
}
document.getElementById("output").innerHTML = result;
console.timeEnd("Build string then DOM (GOOD)");
Uncached length: 12.45ms
Cached length: 8.23ms
Reverse loop: 7.89ms
DOM in loop (BAD): 890.34ms
Build string then DOM (GOOD): 2.15ms
Memory Management
Memory leaks slowly kill JavaScript applications — it seems fine initially but over time the app becomes increasingly sluggish.
Common Memory Leak Patterns
// ❌ MEMORY LEAK: Forgotten event listeners
function createLeakyComponent() {
const button = document.getElementById("myButton");
const hugeData = new Array(1000000).fill("data"); // Large allocation
// This closure holds reference to hugeData forever!
button.addEventListener("click", function handler() {
console.log(hugeData.length);
});
// Even if component is "destroyed", listener + hugeData stay in memory
}
// ✅ FIX: Proper cleanup with AbortController
function createCleanComponent() {
const button = document.getElementById("myButton");
const hugeData = new Array(1000000).fill("data");
const controller = new AbortController();
button.addEventListener(
"click",
() => {
console.log(hugeData.length);
},
{ signal: controller.signal }
);
// Cleanup function - removes ALL listeners at once
return () => controller.abort();
}
const cleanup = createCleanComponent();
// When done:
cleanup(); // All listeners removed, hugeData can be GC'd
console.log("Component cleaned up - memory freed");
Component cleaned up - memory freed
WeakRef and FinalizationRegistry (ES2021)
// WeakRef: Hold reference without preventing garbage collection
class CacheWithWeakRef {
#cache = new Map();
#registry;
constructor() {
// FinalizationRegistry: Get notified when objects are GC'd
this.#registry = new FinalizationRegistry((key) => {
console.log(`Object for key "${key}" was garbage collected`);
this.#cache.delete(key);
});
}
set(key, value) {
const weakRef = new WeakRef(value);
this.#cache.set(key, weakRef);
this.#registry.register(value, key);
}
get(key) {
const weakRef = this.#cache.get(key);
if (!weakRef) return undefined;
const value = weakRef.deref();
if (!value) {
// Object was garbage collected
this.#cache.delete(key);
return undefined;
}
return value;
}
get size() {
return this.#cache.size;
}
}
// Usage
const cache = new CacheWithWeakRef();
let bigObject = { data: new Array(1000000).fill("x") };
cache.set("myData", bigObject);
console.log("Cache size:", cache.size);
console.log("Data exists:", cache.get("myData") !== undefined);
// If bigObject goes out of scope and is GC'd,
// cache will auto-clean via FinalizationRegistry
bigObject = null;
console.log("Reference cleared - object eligible for GC");
Cache size: 1
Data exists: true
Reference cleared - object eligible for GC
User events like scroll, resize, input fire very rapidly. Without control, these events can trigger heavy operations 100s of times per second.
Debounce — Wait Until Activity Stops
// Debounce: Execute only after user stops triggering for specified delay
function debounce(fn, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// Practical example: Search input
const searchAPI = (query) => {
console.log(`🔍 API call for: "${query}"`);
};
const debouncedSearch = debounce(searchAPI, 300);
// Simulating rapid typing
debouncedSearch("j"); // cancelled
debouncedSearch("ja"); // cancelled
debouncedSearch("jav"); // cancelled
debouncedSearch("java"); // cancelled
debouncedSearch("javas"); // cancelled
debouncedSearch("javascript"); // ✅ Only this executes after 300ms pause
setTimeout(() => {
console.log("Without debounce: 6 API calls");
console.log("With debounce: 1 API call");
console.log("Savings: 83% fewer API calls!");
}, 500);
🔍 API call for: "javascript"
Without debounce: 6 API calls
With debounce: 1 API call
Savings: 83% fewer API calls!
Throttle — Execute at Regular Intervals
// Throttle: Execute at most once per interval
function throttle(fn, interval) {
let lastTime = 0;
let timeoutId = null;
return function (...args) {
const now = Date.now();
const remaining = interval - (now - lastTime);
if (remaining <= 0) {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
lastTime = now;
fn.apply(this, args);
} else if (!timeoutId) {
timeoutId = setTimeout(() => {
lastTime = Date.now();
timeoutId = null;
fn.apply(this, args);
}, remaining);
}
};
}
// Practical example: Scroll handler
let scrollCount = 0;
let throttledCount = 0;
const onScroll = () => {
scrollCount++;
};
const onScrollThrottled = throttle(() => {
throttledCount++;
}, 100);
// Simulating 50 scroll events in 200ms
const interval = setInterval(() => {
onScroll();
onScrollThrottled();
}, 4); // Every 4ms = 50 events in 200ms
setTimeout(() => {
clearInterval(interval);
console.log(`Raw scroll events fired: ${scrollCount}`);
console.log(`Throttled handler called: ${throttledCount} times`);
console.log(`Performance saved: ${((1 - throttledCount / scrollCount) * 100).toFixed(0)}%`);
}, 250);
Raw scroll events fired: 50
Throttled handler called: 3 times
Performance saved: 94%
Memoization Pattern
Memoization caches the results of expensive function calls — it does not recalculate when the same input comes again.
// Generic memoization function
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(` Cache HIT for: ${key}`);
return cache.get(key);
}
console.log(` Cache MISS for: ${key} — computing...`);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
// Example: Expensive fibonacci calculation
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// Memoized version with internal cache
function memoizedFibonacci() {
const memo = {};
function fib(n) {
if (n in memo) return memo[n];
if (n <= 1) return n;
memo[n] = fib(n - 1) + fib(n - 2);
return memo[n];
}
return fib;
}
const fastFib = memoizedFibonacci();
// Performance comparison
console.time("Regular fibonacci(35)");
const result1 = fibonacci(35);
console.timeEnd("Regular fibonacci(35)");
console.time("Memoized fibonacci(35)");
const result2 = fastFib(35);
console.timeEnd("Memoized fibonacci(35)");
console.time("Memoized fibonacci(35) AGAIN");
const result3 = fastFib(35);
console.timeEnd("Memoized fibonacci(35) AGAIN");
console.log(`Result: ${result1}`);
// Generic memoize usage
const expensiveCalc = memoize((x, y) => {
// Simulate heavy computation
let result = 0;
for (let i = 0; i < 1000000; i++) {
result += x * y;
}
return result;
});
console.log("\n--- Generic Memoize Demo ---");
expensiveCalc(5, 10);
expensiveCalc(5, 10); // Cached!
expensiveCalc(3, 7); // New computation
Regular fibonacci(35): 78.45ms
Memoized fibonacci(35): 0.03ms
Memoized fibonacci(35) AGAIN: 0.001ms
Result: 9227465
--- Generic Memoize Demo ---
Cache MISS for: [5,10] — computing...
Cache HIT for: [5,10]
Cache MISS for: [3,7] — computing...
requestAnimationFrame for Animations
requestAnimationFrame syncs with the browser's paint cycle (typically 60fps = every 16.67ms), keeping animations smooth and jank-free.
// ❌ BAD: Using setInterval for animation
function animateWithInterval(element) {
let position = 0;
const intervalId = setInterval(() => {
position += 2;
element.style.transform = `translateX(${position}px)`;
if (position >= 300) clearInterval(intervalId);
}, 16); // Trying to match 60fps — but unreliable!
}
// ✅ GOOD: Using requestAnimationFrame
function animateWithRAF(element) {
let position = 0;
let startTime = null;
function animate(timestamp) {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
position = Math.min(elapsed * 0.2, 300); // 0.2px per ms
element.style.transform = `translateX(${position}px)`;
if (position < 300) {
requestAnimationFrame(animate);
} else {
console.log(`Animation complete in ${elapsed.toFixed(0)}ms`);
}
}
requestAnimationFrame(animate);
}
// Smooth scroll implementation with rAF
function smoothScrollTo(targetY, duration = 600) {
const startY = window.scrollY;
const difference = targetY - startY;
const startTime = performance.now();
function step(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease-out cubic for natural feel
const easeOut = 1 - Math.pow(1 - progress, 3);
window.scrollTo(0, startY + difference * easeOut);
if (progress < 1) {
requestAnimationFrame(step);
}
}
requestAnimationFrame(step);
}
console.log("setInterval animation: May drop frames, not synced with display");
console.log("requestAnimationFrame: Synced with 60fps display refresh");
console.log("Benefits: No jank, battery efficient, auto-pauses in background tabs");
setInterval animation: May drop frames, not synced with display
requestAnimationFrame: Synced with 60fps display refresh
Benefits: No jank, battery efficient, auto-pauses in background tabs
Animation complete in 1500ms
Instead of attaching individual listeners to each element, use a single listener on the parent:
// ❌ BAD: 1000 individual listeners
function setupListenersBad() {
console.time("Individual listeners");
const items = document.querySelectorAll(".menu-item"); // 1000 items
items.forEach((item) => {
item.addEventListener("click", (e) => {
console.log("Clicked:", e.target.textContent);
});
});
console.timeEnd("Individual listeners");
console.log(`Memory: 1000 function objects + 1000 listener registrations`);
}
// ✅ GOOD: 1 delegated listener
function setupListenersGood() {
console.time("Delegated listener");
const container = document.getElementById("menu-container");
container.addEventListener("click", (e) => {
const item = e.target.closest(".menu-item");
if (!item) return;
console.log("Clicked:", item.textContent);
});
console.timeEnd("Delegated listener");
console.log(`Memory: 1 function object + 1 listener registration`);
}
// Performance comparison
setupListenersBad();
setupListenersGood();
console.log("\n--- Summary ---");
console.log("1000 listeners: ~4.5KB memory + slower setup");
console.log("1 delegated listener: ~0.004KB memory + instant setup");
console.log("Bonus: Delegation handles dynamically added elements automatically!");
Individual listeners: 12.34ms
Memory: 1000 function objects + 1000 listener registrations
Delegated listener: 0.08ms
Memory: 1 function object + 1 listener registration
--- Summary ---
1000 listeners: ~4.5KB memory + slower setup
1 delegated listener: ~0.004KB memory + instant setup
Bonus: Delegation handles dynamically added elements automatically!
Web Workers for Heavy Tasks
When the main thread is blocked, the UI freezes. Web Workers perform heavy computation in a background thread:
// main.js — Heavy computation on main thread (BAD)
function heavyTaskOnMainThread() {
console.time("Main thread (blocks UI)");
let result = 0;
for (let i = 0; i < 100000000; i++) {
result += Math.sqrt(i) * Math.sin(i);
}
console.timeEnd("Main thread (blocks UI)");
console.log("⚠️ UI was FROZEN during computation!");
return result;
}
// worker.js — Heavy computation in Web Worker (GOOD)
// File: worker.js
/*
self.onmessage = function(e) {
const { iterations } = e.data;
let result = 0;
for (let i = 0; i < iterations; i++) {
result += Math.sqrt(i) * Math.sin(i);
}
self.postMessage({ result, iterations });
};
*/
// main.js — Using the worker
function heavyTaskWithWorker() {
return new Promise((resolve) => {
console.time("Web Worker (UI stays responsive)");
const worker = new Worker("worker.js");
worker.onmessage = (e) => {
console.timeEnd("Web Worker (UI stays responsive)");
console.log("✅ UI remained RESPONSIVE during computation!");
console.log(`Result: ${e.data.result.toFixed(2)}`);
worker.terminate();
resolve(e.data.result);
};
worker.postMessage({ iterations: 100000000 });
});
}
// Using inline worker with Blob (no separate file needed)
function createInlineWorker(fn) {
const blob = new Blob(
[`self.onmessage = function(e) { self.postMessage((${fn.toString()})(e.data)); }`],
{ type: "application/javascript" }
);
return new Worker(URL.createObjectURL(blob));
}
const sortWorker = createInlineWorker((data) => {
return data.sort((a, b) => a - b);
});
const unsortedData = Array.from({ length: 1000000 }, () => Math.random());
sortWorker.postMessage(unsortedData);
sortWorker.onmessage = (e) => {
console.log(`Sorted ${e.data.length} items in background`);
console.log(`First: ${e.data[0].toFixed(6)}, Last: ${e.data[e.data.length - 1].toFixed(6)}`);
sortWorker.terminate();
};
Main thread (blocks UI): 2340.56ms
⚠️ UI was FROZEN during computation!
Web Worker (UI stays responsive): 2280.12ms
✅ UI remained RESPONSIVE during computation!
Result: -1847234.67
Sorted 1000000 items in background
First: 0.000001, Last: 0.999999
// Virtual scrolling: Only render visible items
class VirtualScroller {
constructor(container, items, itemHeight = 40) {
this.container = container;
this.items = items;
this.itemHeight = itemHeight;
this.visibleCount = Math.ceil(container.clientHeight / itemHeight) + 2;
this.totalHeight = items.length * itemHeight;
this.scrollTop = 0;
this.setup();
}
setup() {
// Create scroll container
this.container.style.overflow = "auto";
this.container.style.position = "relative";
// Spacer for total scroll height
this.spacer = document.createElement("div");
this.spacer.style.height = `${this.totalHeight}px`;
this.container.appendChild(this.spacer);
// Content container
this.content = document.createElement("div");
this.content.style.position = "absolute";
this.content.style.top = "0";
this.content.style.width = "100%";
this.container.appendChild(this.content);
// Throttled scroll handler
this.container.addEventListener("scroll", throttle(() => {
this.render();
}, 16));
this.render();
}
render() {
const scrollTop = this.container.scrollTop;
const startIndex = Math.floor(scrollTop / this.itemHeight);
const endIndex = Math.min(startIndex + this.visibleCount, this.items.length);
this.content.style.transform = `translateY(${startIndex * this.itemHeight}px)`;
this.content.innerHTML = "";
for (let i = startIndex; i < endIndex; i++) {
const div = document.createElement("div");
div.style.height = `${this.itemHeight}px`;
div.textContent = this.items[i];
this.content.appendChild(div);
}
}
}
// Usage
const items = Array.from({ length: 100000 }, (_, i) => `Item ${i + 1}`);
console.log(`Total items: ${items.length}`);
console.log(`Without virtual scroll: 100,000 DOM nodes`);
console.log(`With virtual scroll: ~20 DOM nodes (visible only)`);
console.log(`Memory savings: ~99.98%`);
Total items: 100000
Without virtual scroll: 100,000 DOM nodes
With virtual scroll: ~20 DOM nodes (visible only)
Memory savings: ~99.98%
🚫 Common Mistakes
Mistake 1: Layout Thrashing
// ❌ WRONG: Reading and writing DOM in alternating pattern
function layoutThrash(elements) {
elements.forEach((el) => {
const width = el.offsetWidth; // Force layout calc
el.style.width = width + 10 + "px"; // Invalidate layout
// Next read forces ANOTHER full layout calculation!
});
}
// Performance: O(n) forced layouts = EXTREMELY slow
// ✅ CORRECT: Separate read and write phases
function layoutBatched(elements) {
const widths = elements.map((el) => el.offsetWidth); // All reads first
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + "px"; // All writes together
});
}
// Performance: 1 forced layout = fast
console.log("Thrashing 100 elements: ~200ms");
console.log("Batched 100 elements: ~2ms");
console.log("Improvement: 100x faster!");
Thrashing 100 elements: ~200ms
Batched 100 elements: ~2ms
Improvement: 100x faster!
Mistake 2: Creating Functions in Loops
// ❌ WRONG: New function object created every iteration
function attachHandlersBad(buttons) {
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener("click", function () {
console.log("Button " + i + " clicked");
});
}
console.log(`Created ${buttons.length} function objects`);
}
// ✅ CORRECT: Single function reference
function attachHandlersGood(buttons) {
function handleClick(e) {
const index = e.target.dataset.index;
console.log("Button " + index + " clicked");
}
for (let i = 0; i < buttons.length; i++) {
buttons[i].dataset.index = i;
buttons[i].addEventListener("click", handleClick);
}
console.log(`Created 1 function object for ${buttons.length} buttons`);
}
attachHandlersBad(new Array(500));
attachHandlersGood(new Array(500));
Created 500 function objects
Created 1 function object for 500 buttons
Mistake 3: Uncontrolled Event Handlers
// ❌ WRONG: Raw scroll handler fires 100+ times per scroll
window.addEventListener("scroll", () => {
// This runs every ~10ms during scroll!
const header = document.querySelector(".header");
const scrollY = window.scrollY;
header.style.transform = `translateY(${scrollY * 0.5}px)`;
header.style.opacity = 1 - scrollY / 500;
console.log("Scroll event fired!"); // Fires 60+ times/second
});
// ✅ CORRECT: Throttled with requestAnimationFrame
let ticking = false;
window.addEventListener("scroll", () => {
if (!ticking) {
requestAnimationFrame(() => {
const header = document.querySelector(".header");
const scrollY = window.scrollY;
header.style.transform = `translateY(${scrollY * 0.5}px)`;
header.style.opacity = 1 - scrollY / 500;
ticking = false;
});
ticking = true;
}
});
console.log("Raw handler: 60+ calls/second during scroll");
console.log("rAF throttled: Max 60 calls/second, synced with paint");
console.log("Result: Smooth parallax without jank");
Raw handler: 60+ calls/second during scroll
rAF throttled: Max 60 calls/second, synced with paint
Result: Smooth parallax without jank
Mistake 4: String Concatenation in Large Loops
// ❌ WRONG: String concatenation creates new string every iteration
function buildHTMLBad(items) {
console.time("String concatenation");
let html = "";
for (let i = 0; i < items.length; i++) {
html += "<div class='item'>" + items[i] + "</div>"; // New string each time!
}
console.timeEnd("String concatenation");
return html;
}
// ✅ CORRECT: Array join — much faster for large strings
function buildHTMLGood(items) {
console.time("Array join");
const parts = [];
for (let i = 0; i < items.length; i++) {
parts.push("<div class='item'>", items[i], "</div>");
}
console.timeEnd("Array join");
return parts.join("");
}
// ✅ ALSO GOOD: Template literals with map
function buildHTMLBest(items) {
console.time("Template map");
const html = items.map((item) => `<div class='item'>${item}</div>`).join("");
console.timeEnd("Template map");
return html;
}
const testItems = Array.from({ length: 50000 }, (_, i) => `Item ${i}`);
buildHTMLBad(testItems);
buildHTMLGood(testItems);
buildHTMLBest(testItems);
String concatenation: 156.78ms
Array join: 12.34ms
Template map: 14.56ms
Mistake 5: Not Using Web APIs for Heavy Operations
// ❌ WRONG: Manual deep clone with JSON (blocks main thread for large objects)
function deepCloneBad(obj) {
console.time("JSON clone");
const clone = JSON.parse(JSON.stringify(obj));
console.timeEnd("JSON clone");
return clone;
}
// ✅ CORRECT: structuredClone (native, handles more types)
function deepCloneGood(obj) {
console.time("structuredClone");
const clone = structuredClone(obj);
console.timeEnd("structuredClone");
return clone;
}
// ❌ WRONG: Manual array search
function findItemBad(arr, id) {
console.time("Array find");
const item = arr.find((item) => item.id === id);
console.timeEnd("Array find");
return item;
}
// ✅ CORRECT: Use Map for O(1) lookups
function findItemGood(map, id) {
console.time("Map lookup");
const item = map.get(id);
console.timeEnd("Map lookup");
return item;
}
const largeObj = { data: Array.from({ length: 100000 }, (_, i) => ({ id: i, value: `v${i}` })) };
deepCloneBad(largeObj);
deepCloneGood(largeObj);
const arr = largeObj.data;
const map = new Map(arr.map((item) => [item.id, item]));
findItemBad(arr, 99999);
findItemGood(map, 99999);
JSON clone: 89.23ms
structuredClone: 45.67ms
Array find: 3.45ms
Map lookup: 0.002ms
🎯 Key Takeaways
- Measure First: Always profile before optimizing — use
performance.now() and Chrome DevTools - Minimize DOM Access: Batch DOM reads/writes, use DocumentFragment, avoid layout thrashing
- Control Event Handlers: Debounce for input events, Throttle for continuous events (scroll, resize)
- Memoize Expensive Calculations: Don't recompute for the same inputs — cache results
- Use requestAnimationFrame: For animations, use rAF instead of setInterval — guarantees smooth 60fps
- Web Workers for CPU-Heavy Tasks: Instead of blocking the main thread, run heavy computation in a background thread
- Prevent Memory Leaks: Remove event listeners, monitor closures, use WeakRef/WeakMap
- Choose the Right Data Structure: Use Map/Set for object lookups — O(1) vs O(n)
- Virtual Rendering: Render large lists virtually — keep only visible items in the DOM
- Prefer Composite-Only Properties: Use
transform and opacity in animations — cheapest repaint path
❓ Interview Questions
Q1: What is Layout Thrashing and how do you avoid it?
Answer: Layout thrashing occurs when JavaScript repeatedly reads and writes DOM properties in an alternating pattern. Every read after a write forces the browser to calculate a forced synchronous layout.
Fix: Perform all reads first (batch), then all writes. Or use requestAnimationFrame to separate reads and writes.
Q2: What is the difference between Debounce and Throttle? When to use which?
Answer:
- Debounce: Function executes only when the user has not triggered a new event for a specified time. Use case: search input, form validation, window resize end.
- Throttle: Function executes at most once per specified interval, no matter how many events occur. Use case: scroll handler, mousemove tracking, API rate limiting.
Q3: How do you detect and fix Memory Leaks?
Answer: Detection: Chrome DevTools → Memory tab → Take Heap Snapshot → Compare snapshots over time. Growing memory = leak.
Common causes & fixes:
- Forgotten event listeners → Use
AbortController or cleanup functions - Closures holding large references → Nullify references when done
- Detached DOM nodes → Remove references to removed elements
- Global variables → Minimize globals, use modules
- setInterval without clearInterval → Always store and clear interval IDs
Q4: When should you use Web Workers?
Answer: Use Web Workers when:
- Heavy computation (sorting large arrays, complex calculations, image processing)
- Operation takes more than 16ms (will cause UI jank)
- Data parsing (large JSON, CSV processing)
- Encryption/hashing operations
You should NOT use them when:
- DOM access is needed (Workers can't access DOM)
- Simple/fast operations (Worker creation overhead would be more)
- Shared mutable state is required (Workers use message passing)
Answer: Virtual DOM (React, Vue) minimizes real DOM changes:
- On state change, a new virtual tree is created (JavaScript object — fast)
- Diffing algorithm compares old vs new tree
- Generates a minimal set of actual DOM operations (batch update)
Real benefit: Manual DOM manipulation mistakes are avoided, and the framework automatically batches updates. But well-optimized vanilla JS can often be faster — Virtual DOM is a convenience vs raw performance tradeoff.
Q6: How do you optimize the Critical Rendering Path?
Answer:
- Keep CSS in
<head> (render-blocking resource — loads early) - Place JavaScript before
</body> or with defer/async attribute - Inline critical CSS (above-the-fold content)
- Lazy load non-critical resources
- Give images proper dimensions (to avoid CLS)
- Use
font-display: swap for custom fonts
Q7: What is the use case for requestIdleCallback?
Answer: requestIdleCallback schedules low-priority work during browser idle periods — it executes when the main thread is free.
Use cases: Analytics tracking, pre-fetching data, non-urgent DOM updates, cache warming.
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0) {
// Do low-priority work
processNextItem();
}
}, { timeout: 2000 }); // Fallback: must run within 2 seconds
📝 FAQ
Measure! Use Chrome DevTools' Performance tab to identify actual bottlenecks. Avoid premature optimization — first profile, then optimize the slowest parts. Use performance.now() for precise measurements.
2. Do modern browsers automatically optimize code?
Yes! V8 (Chrome), SpiderMonkey (Firefox), and JavaScriptCore (Safari) — all perform optimizations like JIT compilation, inline caching, and hidden classes. But the browser cannot automatically perform developer-level optimizations (DOM batching, event delegation, memory management). Algorithm choice and data structure selection are always the developer's responsibility.
3. Is manual optimization still necessary when using frameworks like React/Vue?
Absolutely! Frameworks provide Virtual DOM but you still need to:
- Need to use
useMemo/React.memo to avoid unnecessary re-renders - Virtualize large lists (react-window, react-virtualized)
- Move heavy computations to Web Workers
- Properly cleanup event handlers
- Optimize bundle size (code splitting, tree shaking)
4. What should you do on the JavaScript side to improve Core Web Vitals?
- LCP (Largest Contentful Paint): Load critical resources early, minimize render-blocking JS, use
defer/async - FID/INP (First Input Delay / Interaction to Next Paint): Minimize main thread blocking, break long tasks (yield to main thread), use Web Workers
- CLS (Cumulative Layout Shift): Reserve space for dynamic content, give images/ads fixed dimensions, avoid layout shift when fonts load
- Lighthouse CI: Automated Lighthouse audit on every PR
- Web Vitals library: Track real user metrics (RUM)
- Bundle analyzer: Catch bundle size regressions
- Performance budgets: Set size limits in webpack/vite
- Playwright/Puppeteer: Automated performance tests with metrics extraction
// Example: Performance budget check in build
const MAX_BUNDLE_SIZE = 250 * 1024; // 250KB
const bundleSize = fs.statSync("dist/main.js").size;
if (bundleSize > MAX_BUNDLE_SIZE) {
throw new Error(`Bundle too large: ${(bundleSize/1024).toFixed(1)}KB > 250KB limit`);
}