---
title: 'Debounce, throttle, and yield in event handlers'
description: >-
  Use debouncing, throttling, passive event listeners, and
  `requestAnimationFrame` to reduce event handler execution time and improve
  Interaction to Next Paint (INP).
source_url:
  html: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/debounce-throttle-event-handlers
  md: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/debounce-throttle-event-handlers.md
api_name: liquid
---

# 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)](https://web.dev/inp/).

***

## Why

Event handlers execute synchronously on the main thread. Expensive handlers block the browser from responding to user input, directly harming [INP](https://web.dev/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.

***

## How

### Debounce

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

```javascript
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);
```

### Throttle

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

```javascript
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);
```

### Use passive event listeners

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

```javascript
// 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);
```

### Use `requestAnimationFrame` for visual updates

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

```javascript
// 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 }
);
```

### Break up long tasks

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

```javascript
// 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));
    }
  }
});
```

### Yield 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:

```javascript
// 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.

### Batch 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.

```javascript
// 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.

### Avoid 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.

```css
/* 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.

### Offload to Web Workers

Move computationally expensive work off the main thread entirely.

```javascript
// 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);
});
```

***

## Examples

### Real-world: product filter

```javascript
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);
});
```

### Real-world: infinite scroll

```javascript
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;
}
```

### Real-world: parallax effect

```javascript
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 }
);
```

***

## Testing

* **[Chrome DevTools Performance panel](https://developer.chrome.com/docs/devtools/performance)**: 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.

***

## References

* [Optimize long tasks](https://web.dev/optimize-long-tasks/)
* [Debouncing and throttling explained](https://css-tricks.com/debouncing-throttling-explained-examples/)
* [Using passive event listeners](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#using_passive_listeners)
* [requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame)
* [Scheduler.yield() proposal](https://github.com/WICG/scheduling-apis/blob/main/explainers/yield-and-continuation.md)
* [Using Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers)
* [hover media query](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover)
* [Understanding INP](https://shopify.dev/docs/storefronts/themes/best-practices/performance/understanding-inp)
* [Audit and remove third-party scripts](https://shopify.dev/docs/storefronts/themes/best-practices/performance/audit-remove-third-party-scripts)
* [Defer non-critical scripts](https://shopify.dev/docs/storefronts/themes/best-practices/performance/defer-scripts)
* [Optimize view transitions for better interactions](https://shopify.dev/docs/storefronts/themes/best-practices/performance/cancel-view-transitions-on-interaction)

***
