JavaScript Performance Optimization
Practical JavaScript performance techniques — debounce, throttle, lazy loading, avoiding layout thrash, V8 optimization, memory leak prevention, and Web Workers.
Performance problems in JavaScript usually fall into three categories: doing too much on the main thread, allocating memory that never gets freed, and triggering expensive browser operations too frequently. This guide addresses all three with concrete implementations you can adapt directly.
Debounce
When a function is called in rapid succession — such as on every keystroke in a search field — debouncing delays execution until the burst of calls stops. Without debouncing, a search input would fire an API request on every single keystroke, overwhelming the server and making results flicker. With a 300ms debounce, the request fires only after the user pauses typing.
function debounce(fn, delay) {
let timerId = null;
return function (...args) {
clearTimeout(timerId); // cancel the previous timer on each call
timerId = setTimeout(() => {
fn.apply(this, args);
timerId = null;
}, delay);
};
}
// Usage — fires only after user stops typing for 300ms
const searchInput = document.getElementById("search");
const handleSearch = debounce(async (query) => {
if (!query.trim()) return;
const results = await fetchSearchResults(query);
renderResults(results);
}, 300);
searchInput.addEventListener("input", (e) => handleSearch(e.target.value));
For a version with a leading call (fires immediately on the first event, then waits before firing again):
function debounce(fn, delay, { leading = false } = {}) {
let timerId = null;
return function (...args) {
const callNow = leading && !timerId;
clearTimeout(timerId);
timerId = setTimeout(() => {
timerId = null;
if (!leading) fn.apply(this, args);
}, delay);
if (callNow) fn.apply(this, args); // fire immediately on first call
};
}
Throttle
While debounce waits for silence, throttle enforces a maximum call rate. A throttled function fires at most once per interval, regardless of how many times it’s called. This is the right tool for scroll and resize handlers — you want regular updates during continuous events, just not one per animation frame.
function throttle(fn, interval) {
let lastCall = 0;
let timerId = null;
return function (...args) {
const now = Date.now();
const remaining = interval - (now - lastCall);
if (remaining <= 0) {
// Enough time has passed — fire immediately
clearTimeout(timerId);
lastCall = now;
fn.apply(this, args);
} else {
// Schedule a trailing call so the last event in the burst still fires
clearTimeout(timerId);
timerId = setTimeout(() => {
lastCall = Date.now();
fn.apply(this, args);
}, remaining);
}
};
}
// Usage: update a sticky header at most every 100ms during scrolling
const handleScroll = throttle(() => {
const scrollY = window.scrollY;
header.classList.toggle("sticky", scrollY > 80);
updateProgressBar(scrollY);
}, 100);
// passive: true tells the browser you won't call preventDefault, enabling scroll optimizations
window.addEventListener("scroll", handleScroll, { passive: true });
Lazy Loading with Dynamic Import
Every byte of JavaScript that loads on page start is JavaScript that must be parsed and compiled before the page becomes interactive. Dynamic import() lets you defer loading large modules until the moment they’re needed. This is the foundation of code splitting — instead of one large bundle, users download only what the current page requires.
// Before: everything loads upfront, even on pages that never use Chart.js
import { renderChart } from "./chart.js"; // large dependency always bundled in
// After: chart.js downloads only when the user actually clicks "Show Chart"
async function showDashboard() {
const button = document.getElementById("show-chart");
button.addEventListener("click", async () => {
button.disabled = true;
button.textContent = "Loading...";
try {
// chart.js (and its dependencies) are downloaded here, not on page load
const { renderChart } = await import("./chart.js");
renderChart(document.getElementById("chart-container"), data);
} finally {
button.disabled = false;
button.textContent = "Show Chart";
}
});
}
// Route-based splitting in a SPA — each page is a separate download
async function navigate(route) {
const routeModules = {
"/dashboard": () => import("./pages/Dashboard.js"),
"/settings": () => import("./pages/Settings.js"),
"/profile": () => import("./pages/Profile.js"),
};
const loader = routeModules[route];
if (!loader) return show404();
const { default: Page } = await loader();
renderPage(new Page());
}
Avoiding Layout Thrash
The browser batches DOM writes efficiently, but reading a layout property (like offsetWidth or getBoundingClientRect) immediately after a write forces it to flush those batched writes and recalculate layout synchronously. This is called a “forced synchronous layout” or layout thrash. In a loop, it can cause dozens of expensive layout recalculations per frame — a common cause of janky animations.
// Bad: alternating reads and writes — a layout recalculation on every iteration
function badResize(elements) {
elements.forEach((el) => {
const width = el.offsetWidth; // READ — forces browser to recalculate layout
el.style.height = width + "px"; // WRITE — invalidates the just-calculated layout
const height = el.offsetHeight; // READ — forces layout recalculation again
el.style.width = height + "px"; // WRITE
});
}
// Good: all reads first, then all writes — browser recalculates layout once
function goodResize(elements) {
// Phase 1: read all layout values (one layout calculation)
const dimensions = elements.map((el) => ({
el,
width: el.offsetWidth,
height: el.offsetHeight,
}));
// Phase 2: write all styles (browser batches these until next frame)
dimensions.forEach(({ el, width, height }) => {
el.style.height = width + "px";
el.style.width = height + "px";
});
}
// For animations: use requestAnimationFrame to coordinate read/write phases across frames
function animateExpand(el, targetHeight) {
requestAnimationFrame(() => {
const currentHeight = el.offsetHeight; // read phase — in rAF callback
requestAnimationFrame(() => {
// write phase — separate frame prevents layout thrash
el.style.transition = "height 0.3s ease";
el.style.height = targetHeight + "px";
});
});
}
Use the fastdom library for larger codebases — it queues reads and writes automatically and schedules them in the correct order.
V8 Optimization Tips
V8 (Chrome/Node.js) can JIT-compile “hot” functions to near-native speed. But it deoptimizes when it encounters patterns it can’t predict — inconsistent object shapes, mixed array types, or use of legacy features like arguments. These patterns force V8 to fall back to slower interpreted execution.
// 1. Monomorphic functions — keep argument types consistent
// Bad: V8 sees objects with different shapes and can't generate optimized code
function process(value) {
return value.x + value.y;
}
process({ x: 1, y: 2 }); // shape A
process({ x: 1, y: 2, z: 3 }); // shape B — triggers deoptimization
// Good: consistent object shapes — V8 specializes the compiled code
const point2D = (x, y) => ({ x, y });
process(point2D(1, 2));
process(point2D(3, 4)); // same shape every time — stays optimized
// 2. Avoid delete — it changes the object's hidden class (shape)
// Bad
const obj = { a: 1, b: 2, c: 3 };
delete obj.b; // forces V8 to create a new hidden class for this object
// Better: set to undefined or null — object shape stays the same
obj.b = undefined;
// 3. Pre-allocate arrays when size is known; use TypedArrays for numeric data
// Bad: array grows dynamically and holds mixed types
const arr = [];
arr.push(1);
arr.push("two"); // now a mixed array — numeric operations deoptimize
// Good: TypedArrays for number-heavy operations (image processing, simulations, etc.)
const floats = new Float64Array(1000);
for (let i = 0; i < 1000; i++) floats[i] = Math.random();
// 4. Use rest params instead of the arguments object in hot functions
// Bad — arguments object prevents certain V8 optimizations
function sum() {
let total = 0;
for (let i = 0; i < arguments.length; i++) total += arguments[i];
return total;
}
// Good — rest params are optimizable
function sum(...nums) {
return nums.reduce((a, b) => a + b, 0);
}
Memory Leaks
JavaScript has garbage collection, but you can still leak memory by holding references longer than needed. The GC cannot free an object if anything still points to it — even indirectly through a closure or an event listener. Over time these leaks cause the browser tab to consume more and more RAM until it crashes.
// Leak 1: event listeners not removed
// The Tooltip instance is never GC'd as long as the DOM element exists,
// because the element holds a reference back to the Tooltip via the listener.
class Tooltip {
constructor(el) {
this.el = el;
this.show = this.show.bind(this); // bind creates a new function — save it to remove later
this.hide = this.hide.bind(this);
el.addEventListener("mouseenter", this.show);
el.addEventListener("mouseleave", this.hide);
}
destroy() {
// Remove listeners using the same references that were registered
this.el.removeEventListener("mouseenter", this.show);
this.el.removeEventListener("mouseleave", this.hide);
this.el = null; // break the reference to the DOM element
}
}
// Leak 2: closures holding large data
function createProcessor() {
const largeBuffer = new ArrayBuffer(10 * 1024 * 1024); // 10MB
return {
process(data) {
// largeBuffer stays alive as long as the returned object is reachable
return processWithBuffer(data, largeBuffer);
},
};
}
// Leak 3: unbounded caches — use WeakMap for object-keyed caches
// Bad: domElement removed from DOM? It's still in the Map and can't be GC'd.
const cache = new Map();
cache.set(domElement, computedValue);
// Good: WeakMap — the key is held weakly; when domElement is GC'd, the entry disappears
const weakCache = new WeakMap();
weakCache.set(domElement, computedValue);
// Leak 4: forgotten timers — the callback closure keeps everything it references alive
function startPolling(url, callback) {
const intervalId = setInterval(async () => {
const data = await fetch(url).then((r) => r.json());
callback(data);
}, 5000);
// Return a cleanup function — ALWAYS call it when the component unmounts
return () => clearInterval(intervalId);
}
const stopPolling = startPolling("/api/updates", updateUI);
// On component unmount or page navigation:
stopPolling();
Web Workers for CPU Work
Offloading CPU-heavy computation to a Web Worker keeps the main thread free for rendering and user interactions. Workers communicate with the main thread via postMessage. For large data buffers, use Transferable objects to move them to the worker without copying — a zero-copy transfer.
// compression.worker.js — runs in a background thread
self.onmessage = async ({ data: { imageData, quality } }) => {
const compressed = compressImage(imageData, quality); // CPU-bound — won't block UI
// Transfer the buffer back instead of copying it (zero-copy, much faster for large data)
self.postMessage({ compressed }, [compressed.buffer]);
};
// main.js — UI stays interactive the entire time
const worker = new Worker(new URL("./compression.worker.js", import.meta.url), {
type: "module",
});
async function compressInWorker(imageData, quality) {
return new Promise((resolve, reject) => {
worker.onmessage = ({ data }) => resolve(data.compressed);
worker.onerror = reject;
// Transfer the buffer to the worker — avoids a potentially large copy
worker.postMessage({ imageData, quality }, [imageData.buffer]);
});
}
Quick Reference: When to Use What
| Problem | Tool |
|---|---|
| Rapid-fire input events | Debounce |
| Scroll/resize handlers | Throttle + { passive: true } |
| Large JS bundle | Dynamic import() |
| Off-screen images | IntersectionObserver |
| DOM read/write ordering | Batch reads, then writes |
| CPU-heavy computation | Web Worker |
| DOM-keyed caches | WeakMap |
| Repeated allocations | Object pooling / TypedArrays |