Some events fire extremely often — typing, scrolling, resizing. Debounce waits until the events stop for a moment (ideal for search-as-you-type); throttle runs at most once per interval (ideal for scroll position).
Animate with requestAnimationFrame, which runs once per screen refresh and pauses in background tabs. Avoid reading layout (such as offsetHeight) right after writing styles in a loop — it forces repeated layout calculations.
IntersectionObserver tells you when elements enter the viewport, without scroll listeners — use it for lazy-loading images and infinite lists. Measure before optimising with the browser DevTools Performance panel and Lighthouse.
export function debounce(fn, wait = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
export function throttle(fn, interval = 100) {
let last = 0;
let trailing;
return (...args) => {
const now = Date.now();
const remaining = interval - (now - last);
clearTimeout(trailing);
if (remaining <= 0) {
last = now;
fn(...args);
} else {
trailing = setTimeout(() => {
last = Date.now();
fn(...args);
}, remaining);
}
};
}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Performance</title>
<style>
.card { height: 160px; margin: 12px 0; background: #e9edf2; display: grid; place-items: center; }
.card.loaded { background: #e8f7e7; }
#bar { position: fixed; top: 0; left: 0; height: 4px; background: #2e8b2a; width: 0; }
</style>
<script type="module" src="main.js"></script>
</head>
<body>
<div id="bar"></div>
<input id="search" placeholder="Search subjects..." />
<p id="searching"></p>
<div id="cards"></div>
</body>
</html>import { debounce, throttle } from "./timing.js";
// 1. Debounced search: runs once the user pauses typing
const status = document.querySelector("#searching");
const search = debounce((term) => {
status.textContent = term ? `Searching for "${term}"...` : "";
}, 300);
document.querySelector("#search").addEventListener("input", (e) => search(e.target.value));
// 2. Throttled scroll progress, drawn in requestAnimationFrame
const bar = document.querySelector("#bar");
let progress = 0;
const onScroll = throttle(() => {
const max = document.documentElement.scrollHeight - innerHeight;
progress = max > 0 ? scrollY / max : 0;
requestAnimationFrame(() => { bar.style.width = `${progress * 100}%`; });
}, 50);
addEventListener("scroll", onScroll, { passive: true });
// 3. Lazy content with IntersectionObserver
const cards = document.querySelector("#cards");
for (let i = 1; i <= 30; i++) {
const card = document.createElement("div");
card.className = "card";
card.dataset.index = i;
card.textContent = "...";
cards.append(card);
}
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const card = entry.target;
card.textContent = `Lesson ${card.dataset.index} loaded`;
card.classList.add("loaded");
observer.unobserve(card); // load once
}
}, { rootMargin: "200px" }); // start a little before it's visible
document.querySelectorAll(".card").forEach((card) => observer.observe(card));Key points
- Debounce bursty input; throttle continuous events like scroll.
- Animate in
requestAnimationFrame; don't interleave layout reads and writes. - Use IntersectionObserver for lazy loading; measure with DevTools and Lighthouse.
Exercise
Turn the cards into an infinite list: when the last card becomes visible, append 20 more. Use the Performance panel to compare scrolling with and without throttling the scroll handler.
Show solution
Try the exercise yourself first — then compare your approach with this one.
A second IntersectionObserver watches only the last card. When it becomes visible, 20 more cards are appended, observed by the lazy-load observer, and the sentinel moves to the new last card — so the list grows forever without any scroll listener.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Infinite list</title>
<style>
.card { height: 120px; margin: 10px 0; background: #e9edf2; display: grid; place-items: center; }
.card.loaded { background: #e8f7e7; }
</style>
<script type="module" src="main.js"></script>
</head>
<body>
<p>Cards: <span id="total">0</span></p>
<div id="cards"></div>
</body>
</html>const cards = document.querySelector("#cards");
let count = 0;
const lazy = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.textContent = `Lesson ${entry.target.dataset.index}`;
entry.target.classList.add("loaded");
lazy.unobserve(entry.target);
}
}, { rootMargin: "200px" });
const sentinel = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) addCards(20);
}, { rootMargin: "300px" });
function addCards(n) {
for (let i = 0; i < n; i++) {
const card = document.createElement("div");
card.className = "card";
card.dataset.index = ++count;
card.textContent = "...";
cards.append(card);
lazy.observe(card);
}
document.querySelector("#total").textContent = count;
sentinel.disconnect();
sentinel.observe(cards.lastElementChild); // watch the new last card
}
addCards(20);