Performance Optimization in JavaScript
Imagine two restaurants. Both serve the same food. One gets your order in 5 minutes, the other in 30. Which one would you return to?
Performance optimization in JavaScript is about making your code faster, smoother, and less wasteful. It means your web pages load quickly, buttons respond instantly, and animations do not stutter.
Why does this matter? Studies show that users leave a page if it takes more than 3 seconds to load. Slow JavaScript is one of the biggest reasons pages feel sluggish — even on fast internet connections.
Performance problems usually come from a few places:
- Doing too much work on the main thread (which blocks the browser from rendering)
- Making too many or unnecessary DOM changes
- Running expensive functions too frequently (on every scroll or keypress)
- Keeping too many things in memory when you no longer need them
- Loading too much JavaScript upfront before it is actually needed
Each section in this tutorial covers one of these problems — and shows you the technique to fix it. You do not need to apply every technique to every project. Just knowing they exist puts you ahead of most developers.
Good to know: Performance is also a topic interviewers love. Questions like "How would you optimise a slow search box?" or "What is debouncing?" are extremely common.JavaScript is single-threaded. It has one main thread that handles everything — running your code, responding to clicks, and updating what you see on screen.
When you run heavy synchronous code, you block that thread. While it is blocked, the browser cannot do anything else. Clicks do not work. The page freezes. Animations stop. This is called a blocking operation.
Think of a bank with one cashier. If someone walks up and takes 20 minutes sorting through their paperwork, everyone else has to wait. Nobody gets served until that person is done. That is exactly what happens when you block the main thread.
// BAD – blocks the main thread for several seconds
function heavyTask() {
const start = Date.now();
// Simulate heavy work – page freezes during this!
while (Date.now() - start < 3000) {
// spinning for 3 seconds...
}
console.log("Done");
}
heavyTask(); // Browser is frozen for 3 seconds
The fix is to use asynchronous code whenever possible — setTimeout, Promises, async/await, or Web Workers for truly heavy work. Async code does not block the thread — it schedules work and lets the browser stay responsive in between.
// BETTER – async code doesn't block the main thread
async function fetchData(url) {
const response = await fetch(url); // browser stays responsive
const data = await response.json();
console.log(data);
}
fetchData("https://api.example.com/users");
// The browser can still respond to clicks and render frames
// while waiting for the network request to complete.
Whenever you have work that takes time — loading data, processing large arrays, doing calculations — always ask: "Can I do this asynchronously?" If the answer is yes, do it that way.
Every time you change something in the DOM — add an element, change a class, update text — the browser has to recalculate the layout and repaint the screen. This is called reflow and repaint, and it is expensive.
If you make 100 DOM changes in a loop, the browser might try to reflow 100 times. That is very slow.
Batch DOM Updates
Instead of making many small DOM changes one by one, batch them all together and apply them at once.
// BAD – 1000 DOM writes, triggers reflow each time
const list = document.getElementById("myList");
for (let i = 0; i < 1000; i++) {
const li = document.createElement("li");
li.textContent = `Item ${i}`;
list.appendChild(li); // DOM reflow on every append!
}
// GOOD – build everything first, then add once
const list2 = document.getElementById("myList2");
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement("li");
li.textContent = `Item ${i}`;
fragment.appendChild(li); // no reflow yet
}
list2.appendChild(fragment); // ONE reflow at the end
DocumentFragment is like a temporary holding area. You build everything inside it — no reflows happen. Then you drop it into the real DOM in one go. Just one reflow.
Cache DOM References
Looking up a DOM element with document.getElementById() or querySelector() takes time. If you call it repeatedly inside a loop, you are doing the same expensive lookup over and over.
// BAD – looks up the element 1000 times
for (let i = 0; i < 1000; i++) {
document.getElementById("output").textContent = i;
}
// GOOD – look it up once, reuse the reference
const output = document.getElementById("output");
for (let i = 0; i < 1000; i++) {
output.textContent = i;
}
Store the DOM element in a variable first. Then use the variable. This is a small change that can make a big difference in tight loops.
Use Event Delegation
If you have 100 list items and want to handle clicks on each one, you could add 100 event listeners. But that uses a lot of memory. Instead, use event delegation — add one listener to the parent and let events bubble up.
// BAD – 100 separate listeners
document.querySelectorAll("li").forEach(li => {
li.addEventListener("click", (e) => {
console.log("Clicked:", e.target.textContent);
});
});
// GOOD – one listener on the parent (event delegation)
document.getElementById("myList").addEventListener("click", (e) => {
if (e.target.tagName === "LI") {
console.log("Clicked:", e.target.textContent);
}
});
The click on a <li> bubbles up to the parent <ul>. You catch it there, check what was clicked, and handle it. One listener does the job of many.
Some events fire extremely fast — like scroll, resize, and keyup. If you run an expensive function every time one of these fires, you can easily get hundreds of function calls per second. Your page will crawl.
Two techniques solve this: debounce and throttle.
Debounce
Debounce delays the function call until the user has stopped doing something for a set amount of time. Think of a search box — you do not want to fire an API request on every single keystroke. You want to wait until the user has finished typing, then fire once.
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function searchAPI(query) {
console.log("Searching for:", query);
// ... API call here
}
const debouncedSearch = debounce(searchAPI, 300);
// User types fast – searchAPI is only called once,
// 300ms after the last keystroke
document.getElementById("search").addEventListener("keyup", (e) => {
debouncedSearch(e.target.value);
});
Without debounce, typing "hello" would fire the API 5 times. With debounce (300ms), it fires only once — 300ms after the user stops typing. Much better.
Throttle
Throttle limits how often a function can run — no matter how frequently the event fires. Think of a scroll handler that updates a sticky header. You do not need to run it 60 times per second. Running it once every 200ms is more than enough.
function throttle(fn, limit) {
let lastCall = 0;
return function(...args) {
const now = Date.now();
if (now - lastCall >= limit) {
lastCall = now;
fn.apply(this, args);
}
};
}
function updateHeader() {
console.log("Scroll position:", window.scrollY);
}
const throttledUpdate = throttle(updateHeader, 200);
// Even if scroll fires 60 times/second, updateHeader
// only runs at most 5 times/second (every 200ms)
window.addEventListener("scroll", throttledUpdate);
| Technique | When to use | How it works |
|---|---|---|
| debounce | Search input, form validation, resize handler | Waits until the user stops, then fires once |
| throttle | Scroll events, mouse move, window resize | Fires at most once per time interval, no matter how many events |
Memoization is a technique where you cache the result of a function call and return it immediately the next time the same inputs are used. You do the work once, then remember the answer.
Think of a calculator you can write on. The first time you work out 1234 × 5678, you write the answer down. The next time someone asks, you just look at your notes instead of calculating again. That is memoization.
// WITHOUT memoization – recalculates every time
function slowSquare(n) {
// Imagine this is very expensive
console.log(`Calculating ${n}²`);
return n * n;
}
console.log(slowSquare(5)); // Calculating 5² → 25
console.log(slowSquare(5)); // Calculating 5² → 25 (wasteful)
console.log(slowSquare(5)); // Calculating 5² → 25 (wasteful again)
// WITH memoization – calculates once, then uses cache
function memoize(fn) {
const cache = {};
return function(n) {
if (cache[n] !== undefined) {
console.log(`Cache hit for ${n}`);
return cache[n];
}
console.log(`Calculating ${n}²`);
cache[n] = fn(n);
return cache[n];
};
}
function square(n) { return n * n; }
const fastSquare = memoize(square);
console.log(fastSquare(5)); // Calculating 5² → 25
console.log(fastSquare(5)); // Cache hit for 5 → 25 (no calculation!)
console.log(fastSquare(5)); // Cache hit for 5 → 25 (from cache again)
The memoize wrapper stores results in a cache object. If the same input appears again, the cached result is returned immediately. This is particularly useful for expensive computations like recursion, complex calculations, or API results that rarely change.
A real-world example of memoization you may already know: React.memo — it prevents a component from re-rendering if its props have not changed. Same idea: do not re-do work you already did.
Lazy loading means only loading something when it is actually needed. Instead of loading everything upfront when the page opens, you load things on demand — when the user scrolls to them, clicks something, or navigates to a section.
Think of a restaurant menu with 200 dishes. Instead of bringing 200 dishes to your table before you order, the kitchen only cooks what you actually ask for. That is lazy loading.
Lazy Loading Images
HTML has a built-in attribute for lazy loading images. Just add loading="lazy" to any <img> tag and the browser will not fetch the image until it is about to scroll into view.
<!-- Without lazy loading – loads immediately even if off-screen -->
<img src="big-photo.jpg" alt="Photo">
<!-- With lazy loading – only loads when near the viewport -->
<img src="big-photo.jpg" alt="Photo" loading="lazy">
Lazy Loading JavaScript Modules
You can also lazy-load entire JavaScript modules using dynamic import(). Instead of loading a module at the top of your file (which adds to page load time), you load it only when a user actually triggers that feature.
// Static import – loads the module when the page loads (always)
import { heavyChart } from './chart-library.js';
// Dynamic import – loads the module only when the user clicks
document.getElementById("showChart").addEventListener("click", async () => {
const { heavyChart } = await import('./chart-library.js');
heavyChart.render("#canvas");
});
If most users never click "Show Chart", they never pay the download cost of that library. Lazy loading with dynamic import() is one of the most impactful performance wins in modern JavaScript applications.
Your browser allocates memory to store variables, functions, objects, and DOM elements. When you are done with something, JavaScript's garbage collector should automatically free that memory.
But sometimes, your code accidentally holds onto references it no longer needs. The garbage collector sees those references and thinks the data is still being used — so it never frees the memory. This is called a memory leak.
Over time, memory leaks make your app use more and more RAM. Eventually, the browser tab crashes or the device runs out of memory.
Common Memory Leaks
1. Event listeners that are never removed:
// BAD – adds a new listener every time the function runs
function setup() {
document.getElementById("btn").addEventListener("click", handleClick);
}
// Call setup() multiple times → multiple listeners pile up (leak!)
// GOOD – remove the listener when you are done with it
function setup() {
const btn = document.getElementById("btn");
btn.addEventListener("click", handleClick);
// Later, when the component unmounts:
btn.removeEventListener("click", handleClick);
}
2. Global variables that grow unbounded:
// BAD – logs array grows forever, never cleared
const logs = [];
function logEvent(event) {
logs.push(event); // grows without limit
}
// GOOD – limit the size or clear when no longer needed
const logs = [];
const MAX_LOGS = 100;
function logEvent(event) {
logs.push(event);
if (logs.length > MAX_LOGS) {
logs.shift(); // remove oldest entry
}
}
Use WeakMap and WeakSet for Temporary Data
If you need to associate extra data with an object temporarily, use WeakMap instead of Map. A WeakMap holds weak references — when the object is no longer used anywhere else, the entry is automatically removed from the WeakMap. No memory leak.
// WeakMap – automatically cleaned up when the key object is gone
const cache = new WeakMap();
function processElement(el) {
if (cache.has(el)) {
return cache.get(el); // return cached result
}
const result = el.textContent.trim();
cache.set(el, result); // store only while el is alive
return result;
}
// When the DOM element is removed, the WeakMap entry disappears too.
// No manual cleanup needed.
WeakMap is great for caching DOM element data, storing private metadata for objects, or any scenario where you need the data to disappear automatically when the object it belongs to is gone.
- Avoid blocking the main thread. Use async code (Promises, async/await) for anything time-consuming. Synchronous code that takes too long freezes the browser.
- Batch DOM updates. Use DocumentFragment to make many DOM changes at once instead of one at a time. Each individual DOM change can trigger an expensive reflow.
- Cache DOM references. Look up elements once, store in a variable, then reuse. Repeated querySelector calls inside loops are wasteful.
- Use event delegation. One listener on a parent handles clicks from all child elements. Far more efficient than adding individual listeners to each child.
- Debounce rapid-fire events like search inputs — wait until the user pauses, then fire once. Throttle continuous events like scroll — fire at most once per interval.
- Memoize expensive functions. Cache results by input so identical calls skip the calculation entirely.
- Lazy load images and JavaScript modules. Only load what the user actually needs, when they need it.
- Avoid memory leaks. Remove event listeners when done. Cap growing arrays. Use WeakMap for temporary object-linked data.
- What does it mean to "block the main thread" in JavaScript? How do you avoid it?
- What is reflow and repaint in the browser? How can excessive DOM manipulation cause performance issues?
- What is a DocumentFragment and how does it help with DOM performance?
- What is event delegation? How does it improve performance compared to individual event listeners?
- What is debouncing? Write a simple debounce function from scratch.
- What is throttling? How is it different from debouncing?
- When would you use debounce vs throttle? Give one example of each.
- What is memoization? How does it improve performance?
- What is lazy loading? How would you lazy load a JavaScript module?
- What is a memory leak? Give two examples of common memory leaks in JavaScript.
- How does WeakMap help prevent memory leaks compared to a regular Map?
- What does loading="lazy" do on an image tag?
- If a web page feels slow and unresponsive, what are the first three things you would investigate?