In short
JavaScript performance often comes down to doing less work. Learn debounce and throttle, how to batch DOM updates, avoid layout thrashing, use requestAnimationFrame, and measure speed. What is the difference between debounce and throttle? Some events fire very often.
JavaScript performance often comes down to doing less work. Learn debounce and throttle, how to batch DOM updates, avoid layout thrashing, use requestAnimationFrame, and measure speed.
What is the difference between debounce and throttle?
Some events fire very often. Typing fires input on every key, and scrolling can fire scroll dozens of times per second. If each event runs heavy code (like a fetch call), the page becomes slow.
- Debounce waits until the events stop for a short time, then runs once. Like a lift door: it only closes when people stop walking in.
- Throttle runs at most once every X milliseconds, no matter how many events happen. Like a bus that leaves every 10 minutes, however many people arrive.
Use debounce for search boxes and window resizing ("do it when the user is done"). Use throttle for scroll and mouse movement ("update regularly while it happens").
How to debounce a search box in JavaScript
<input id="search" type="search" placeholder="Search products...">
<p id="status"></p>
<script>
function debounce(fn, delay) {
let timerId;
return (...args) => {
clearTimeout(timerId); // cancel the previous timer
timerId = setTimeout(() => fn(...args), delay); // start a new one
};
}
const status = document.querySelector('#status');
const runSearch = (query) => {
status.textContent = `Searching for "${query}"...`;
console.log('Search request sent:', query);
};
const debouncedSearch = debounce(runSearch, 400);
document.querySelector('#search').addEventListener('input', (event) => {
debouncedSearch(event.target.value.trim());
});
</script>
debounce() returns a new function that uses a closure to remember timerId. Every key press cancels the old timer and starts a new one. Only when you stop typing for 400 ms does runSearch run. Type "laptop" quickly and you see one log line, not six.
How to throttle a scroll event
function throttle(fn, interval) {
let lastTime = 0;
return (...args) => {
const now = Date.now();
if (now - lastTime >= interval) { // enough time has passed?
lastTime = now;
fn(...args);
}
};
}
const updateProgress = () => {
const scrolled = window.scrollY / (document.documentElement.scrollHeight - window.innerHeight);
console.log(`Read ${Math.round(scrolled * 100)}%`);
};
window.addEventListener('scroll', throttle(updateProgress, 200), { passive: true });
// Output while scrolling: about 5 lines per second, e.g. Read 12%, Read 18%...
The throttled function runs at most once every 200 ms. The { passive: true } option is explained below.
Note: This simple throttle may skip the very last event. For a reading-progress bar that is fine. If the final value matters, call the function once more after scrolling stops, or use a library like Lodash's
throttle.
Batching DOM changes and avoiding layout thrashing
Every time you change the page, the browser may need to recalculate layout (the size and position of every element) and then paint (draw the pixels). This is expensive.
Batch your DOM changes
Adding 1,000 items one by one to the live page can trigger lots of extra work. Build them first, then add them in one go.
const list = document.createElement('ul');
document.body.append(list);
const fragment = document.createDocumentFragment(); // an invisible container
for (let i = 1; i <= 1000; i++) {
const li = document.createElement('li');
li.textContent = `Product ${i}`;
fragment.append(li); // not on the page yet
}
list.append(fragment); // one single update to the page
console.log(list.children.length); // Output: 1000
A DocumentFragment is like a shopping basket: you fill it up, then put everything on the counter at once. list.replaceChildren(fragment) is another good option when you want to replace old items.
Avoid layout thrashing
Layout thrashing happens when you read a size (like offsetHeight) and then write a style, again and again in a loop. Each read forces the browser to recalculate layout right away.
const boxes = document.querySelectorAll('.box');
// Slow: read, write, read, write...
boxes.forEach((box) => {
box.style.height = `${box.offsetWidth / 2}px`;
});
// Fast: read everything first, then write everything
const widths = [...boxes].map((box) => box.offsetWidth);
boxes.forEach((box, i) => {
box.style.height = `${widths[i] / 2}px`;
});
The second version does all the reading first, so the browser only calculates layout once. The rule is simple: group reads together, then group writes together.
Tip: Changing a CSS class is often faster and cleaner than setting many inline styles. Let CSS do the styling and let JavaScript just switch classes.
requestAnimationFrame and passive listeners
requestAnimationFrame(callback) asks the browser to run your function right before the next screen repaint, usually 60 times per second. It is the right tool for smooth animations, and it pauses automatically when the tab is hidden.
const ball = document.createElement('div');
ball.style.cssText = 'width:40px;height:40px;border-radius:50%;background:tomato;position:fixed;top:50%;left:0';
document.body.append(ball);
let x = 0;
function move() {
x += 3;
ball.style.transform = `translateX(${x}px)`;
if (x < window.innerWidth - 40) {
requestAnimationFrame(move); // ask for the next frame
}
}
requestAnimationFrame(move);
A red ball slides smoothly across the screen. Each frame moves it 3 pixels and schedules the next frame, until it reaches the edge. Using transform is fast because it does not change layout.
Passive listeners: when you add { passive: true } to scroll, wheel or touchmove listeners, you promise not to call event.preventDefault(). The browser can then scroll immediately without waiting for your code, which feels smoother on phones.
How to measure JavaScript performance
Don't guess, measure. performance.now() gives a very precise time in milliseconds.
const start = performance.now();
const numbers = Array.from({ length: 1_000_000 }, (_, i) => i);
const total = numbers.reduce((sum, n) => sum + n, 0);
const end = performance.now();
console.log(`Total ${total} in ${(end - start).toFixed(1)} ms`);
// Output: Total 499999500000 in 25.3 ms (your time will differ)
For a bigger picture, open DevTools (F12), go to the Performance panel, click record, use your page, then stop. You will see a timeline: long yellow blocks are JavaScript, purple is layout and green is painting. Tasks marked with a red corner are "long tasks" that can make your page feel stuck. Fix the biggest ones first.
Practice exercise
Make a page with a <textarea> and a word counter below it. Debounce the counting so it updates 300 ms after the user stops typing. Then add a throttled scroll listener with { passive: true } that logs window.scrollY at most twice per second.
Hint: reuse the debounce() and throttle() functions from this lesson. Count words with text.trim().split(/\s+/).filter(Boolean).length.
Key takeaways
- Debounce runs once after events stop (search boxes); throttle runs at most every X ms (scroll).
- Build many elements in a DocumentFragment and add them to the page at once.
- Avoid layout thrashing: do all size reads first, then all style writes.
- Use
requestAnimationFramefor animations and{ passive: true }for scroll and touch listeners. - Measure with
performance.now()and the DevTools Performance panel before you optimize.