Skip to main content

Debounce, throttle, and yield in event handlers

Use debouncing, throttling, passive event listeners, and requestAnimationFrame to reduce event handler execution time and improve Interaction to Next Paint (INP).


Event handlers execute synchronously on the main thread. Expensive handlers block the browser from responding to user input, directly harming INP. Common problems include running heavy computations on every scroll or resize event, performing synchronous DOM measurements in handlers, executing multiple layout-triggering operations, and running expensive operations on every keystroke.

INP measures the time from user interaction to visual update. Event handler execution is part of the Processing Time phase, one of three phases that contribute to INP (Input Delay, Processing Time, and Presentation Delay). Optimizing handlers reduces Processing Time, which directly improves INP scores.


Debouncing delays execution until the user stops the action. Use it for search inputs, resize handlers, and form validation.

function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}

// Search input: wait for the user to stop typing
const searchInput = document.querySelector("#search");
const debouncedSearch = debounce((event) => {
performSearch(event.target.value);
}, 300);

searchInput.addEventListener("input", debouncedSearch);

Throttling limits execution frequency. Use it for scroll handlers, mouse move tracking, and resize events.

function throttle(func, limit) {
let inThrottle;
return function (...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}

// Scroll tracking: update at most every 100 ms
const throttledScroll = throttle(() => {
updateScrollPosition();
}, 100);

window.addEventListener("scroll", throttledScroll);

Anchor to Use passive event listenersUse passive event listeners

Passive listeners tell the browser that the handler won't call preventDefault(), allowing the browser to optimize scrolling and touch handling.

// Good: passive listener, the browser can optimize
document.addEventListener("touchstart", handleTouch, { passive: true });
document.addEventListener("wheel", handleWheel, { passive: true });

// Bad: non-passive, the browser must wait for the handler
document.addEventListener("touchstart", handleTouch);

Anchor to Use ,[object Object], for visual updatesUse requestAnimationFrame for visual updates

Use requestAnimationFrame to batch DOM updates and sync with browser paint cycles.

// Bad: updates on every scroll event
window.addEventListener("scroll", () => {
element.style.transform = `translateY(${window.scrollY}px)`;
});

// Good: batched with requestAnimationFrame
let ticking = false;
window.addEventListener(
"scroll",
() => {
if (!ticking) {
requestAnimationFrame(() => {
element.style.transform = `translateY(${window.scrollY}px)`;
ticking = false;
});
ticking = true;
}
},
{ passive: true }
);

Split long-running handlers into chunks using setTimeout or scheduler.yield() when available.

// Bad: long synchronous task blocks the main thread
button.addEventListener("click", () => {
for (let i = 0; i < 10000; i++) {
processItem(i);
}
});

// Good: yield to browser between chunks
button.addEventListener("click", async () => {
const chunkSize = 50;
for (let i = 0; i < 10000; i += chunkSize) {
for (let j = i; j < Math.min(i + chunkSize, 10000); j++) {
processItem(j);
}
// Yield to the browser
await new Promise((resolve) => setTimeout(resolve, 0));
}
});

// Best: use scheduler.yield() when available
button.addEventListener("click", async () => {
const chunkSize = 50;
for (let i = 0; i < 10000; i += chunkSize) {
for (let j = i; j < Math.min(i + chunkSize, 10000); j++) {
processItem(j);
}
// Yield to the browser with the scheduler API
if ("scheduler" in window && "yield" in scheduler) {
await scheduler.yield();
} else {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
});

Anchor to Yield to the main thread for non-critical updatesYield to the main thread for non-critical updates

Defer non-critical updates, such as URL changes and analytics tracking, to avoid blocking the interaction response.

URL updates can trigger tracking pixels synchronously, delaying interactions:

// Bad: URL update blocks the interaction
function handleVariantChange(variant) {
updateProductDisplay(variant); // Critical
updateURL(variant.url); // Triggers tracking pixels, blocks the interaction
}

// Good: yield to the main thread for non-critical updates
function requestYieldCallback(callback) {
requestAnimationFrame(() => {
setTimeout(callback, 0);
});
}

function handleVariantChange(variant) {
updateProductDisplay(variant); // Critical: execute immediately

requestYieldCallback(() => {
updateURL(variant.url); // Non-critical: defer to the next frame
});
}

When to use:

  • URL updates, such as history.pushState or history.replaceState.
  • Analytics tracking calls.
  • Non-visual state updates.
  • Any update that triggers third-party scripts.

Impact: Prevents Facebook, GTM, and other tracking pixels from blocking interactions.

Anchor to Batch DOM reads and writesBatch DOM reads and writes

Group all DOM reads together, then all DOM writes, to avoid layout thrashing from forced synchronous layouts.

Performance profiling of slideshow and carousel components has shown that alternating DOM reads and writes causes expensive layout thrashing. Batching all reads before writes within requestAnimationFrame can improve page load times by 300 ms or more, especially on pages with multiple slideshows.

// Bad: causes multiple reflows (read-write-read-write)
function initSlideshow() {
const width1 = element1.offsetWidth; // Read (triggers layout)
element1.style.left = "10px"; // Write
const width2 = element2.offsetWidth; // Read (forces reflow)
element2.style.left = "20px"; // Write (another reflow)
}

// Good: batch reads, then writes (one reflow)
function initSlideshow() {
requestAnimationFrame(() => {
// Batch all reads first
const width1 = element1.offsetWidth;
const width2 = element2.offsetWidth;
const height1 = element1.offsetHeight;

// Then batch all writes
element1.style.left = "10px";
element2.style.left = "20px";
element1.style.top = `${height1 / 2}px`;
});
}

Impact: Measured improvements of 300 ms or more on complex pages with multiple interactive components.

Common layout-triggering properties (reads):

  • offsetWidth, offsetHeight, offsetTop, and offsetLeft.
  • clientWidth and clientHeight.
  • scrollWidth, scrollHeight, scrollTop, and scrollLeft.
  • getBoundingClientRect().
  • getComputedStyle().

Avoid alternating reads and writes. Batch all reads, then all writes.

Anchor to Avoid hover styles on touch devicesAvoid hover styles on touch devices

Use @media (hover: hover) to prevent hover styles from triggering on mobile taps.

Performance profiling has shown that hover styles trigger expensive style recalculations on mobile devices when users tap elements. Mobile browsers apply :hover pseudo-class styles during tap interactions, causing unnecessary opacity transitions and style recalculations that delay the interaction response.

/* Bad: triggers on mobile taps, expensive opacity animation */
.header__icon:hover {
opacity: 0.7;
}

/* Good: applies only on hover-capable devices */
@media (hover: hover) {
.header__icon:hover {
opacity: 0.7;
}
}

Why this matters:

The hover media query detects whether the device has hover capability (mouse or trackpad) or is touch-only. By wrapping hover styles in this media query, you prevent expensive animations from triggering on mobile taps.

Impact: Eliminates expensive opacity and transform animations from mobile tap interactions. Most noticeable on header and navigation elements.

Anchor to Offload to Web WorkersOffload to Web Workers

Move computationally expensive work off the main thread entirely.

// worker.js
self.addEventListener("message", (e) => {
const result = expensiveComputation(e.data);
self.postMessage(result);
});

// main.js
const worker = new Worker("worker.js");

button.addEventListener("click", () => {
worker.postMessage(inputData);
});

worker.addEventListener("message", (e) => {
displayResults(e.data);
});

Anchor to Real-world: product filterReal-world: product filter

const filterInput = document.querySelector("#product-filter");
const productGrid = document.querySelector("#products");

// Debounce filter updates
const debouncedFilter = debounce((query) => {
const products = Array.from(productGrid.querySelectorAll(".product"));

// Use requestAnimationFrame for DOM updates
requestAnimationFrame(() => {
products.forEach((product) => {
const matches = product.textContent
.toLowerCase()
.includes(query.toLowerCase());
product.style.display = matches ? "" : "none";
});
});
}, 200);

filterInput.addEventListener("input", (e) => {
debouncedFilter(e.target.value);
});

Anchor to Real-world: infinite scrollReal-world: infinite scroll

const loadMore = throttle(() => {
if (isNearBottom() && !loading) {
fetchMoreProducts();
}
}, 200);

// Passive listener + throttling
window.addEventListener("scroll", loadMore, { passive: true });

function isNearBottom() {
const scrollPosition = window.scrollY + window.innerHeight;
const pageHeight = document.documentElement.scrollHeight;
return scrollPosition > pageHeight - 1000;
}

Anchor to Real-world: parallax effectReal-world: parallax effect

const parallaxElements = document.querySelectorAll("[data-parallax]");
let ticking = false;

window.addEventListener(
"scroll",
() => {
if (!ticking) {
requestAnimationFrame(() => {
const scrollY = window.scrollY;

parallaxElements.forEach((el) => {
const speed = el.dataset.parallax || 0.5;
el.style.transform = `translateY(${scrollY * speed}px)`;
});

ticking = false;
});
ticking = true;
}
},
{ passive: true }
);

  • Chrome DevTools Performance panel: Record interactions and identify long tasks in event handlers.
  • INP debugger: Use the web-vitals library to log INP issues in development.
  • Field data: Monitor INP in Shopify's Web Performance dashboards or CrUX data after optimization.


Was this page helpful?