Performance: debouncing, throttling and layout thrashing

Run expensive handlers less often, batch DOM reads and writes, and keep the page responsive while users type, scroll and resize.

  • Beginner
  • 8 min read
  • Updated 17 Oct 2026

What you will learn

  • Debounce and throttle handlers
  • Avoid forced synchronous layout
  • Throttle visual updates to animation frames

Before you start

Some events fire far more often than you need to respond to them. An input event fires on every key press, and scroll, resize and pointermove can fire many times per frame. If each one triggers a search, a network request or a DOM update, the page stutters. This lesson covers the two standard ways to run handlers less often, and the DOM pattern that most often makes handlers slow.

Debounce: wait until things settle

A debounced function runs only after calls have stopped for a set time. Each new call restarts the timer.

JavaScript
function debounce(fn, delay) {
  let timerId;

  function debounced(...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn.apply(this, args), delay);
  }

  debounced.cancel = () => clearTimeout(timerId);
  return debounced;
}

The closure keeps timerId between calls. fn.apply(this, args) passes on the latest arguments and this, so the debounced version can be used as a method or an event handler. cancel() lets you drop a pending call, for example when the component is removed.

Debounce suits "do this once the user has finished":

  • searching as the user types,
  • saving a draft,
  • validating a field,
  • recalculating a layout after a resize ends.

On the course dashboard, the search box filters the list:

HTML
<label for="search">Search courses</label>
<input type="search" id="search" autocomplete="off">
<p id="result-count" role="status" aria-live="polite"></p>
<ul id="course-list"></ul>

<script type="module">
  import { debounce } from './debounce.js';
  import { renderCourses } from './render.js';

  const input = document.querySelector('#search');
  const count = document.querySelector('#result-count');
  let courses = [];

  const runSearch = debounce((query) => {
    const q = query.trim().toLowerCase();
    const matches = courses.filter((course) => course.title.toLowerCase().includes(q));
    renderCourses(matches);
    count.textContent = `${matches.length} courses found`;
  }, 250);

  input.addEventListener('input', () => runSearch(input.value));

  const response = await fetch('data/courses.json');
  courses = await response.json();
  renderCourses(courses);
</script>

debounce.js exports the function above and render.js exports a renderCourses function from earlier lessons. Typing "javascript" quickly now runs one search instead of ten. Debouncing also helps accessibility: the live region announces one result count when the user pauses, instead of a stream of announcements on every key press.

Around 200 to 300 milliseconds is a common delay for typing. Shorter feels more responsive but saves less work; longer feels laggy.

Throttle: run at most once per interval

A throttled function runs at most once in each time window, however many times it is called. Unlike debounce, it keeps running during a long burst of events.

JavaScript
function throttle(fn, interval) {
  let lastRun = 0;
  let timerId = null;
  let lastArgs;
  let lastThis;

  return function throttled(...args) {
    lastArgs = args;
    lastThis = this;
    const now = Date.now();
    const remaining = interval - (now - lastRun);

    if (remaining <= 0) {
      clearTimeout(timerId);
      timerId = null;
      lastRun = now;
      fn.apply(lastThis, lastArgs);
    } else if (timerId === null) {
      timerId = setTimeout(() => {
        timerId = null;
        lastRun = Date.now();
        fn.apply(lastThis, lastArgs);
      }, remaining);
    }
  };
}

The first call runs straight away. Calls within the interval are collapsed, and a trailing call runs at the end of the window with the most recent arguments, so the final state is never lost.

Throttle suits "keep updating, but not too often":

  • sending a position update while dragging,
  • checking scroll position for "load more",
  • logging analytics for continuous activity.
DebounceThrottle
Runs during a burstNoYes, at most once per interval
Runs after a burstOnce, after the delayOnce more, with the latest arguments
Typical usesearch, autosave, validationscroll, drag, pointer tracking

Throttling visual updates to frames

For anything that changes what is on screen, the right interval is one frame. requestAnimationFrame runs a callback just before the browser's next render, so batching visual updates into it means at most one update per frame, in step with the display:

JavaScript
function rafThrottle(fn) {
  let frameId = null;
  let lastArgs;

  return function (...args) {
    lastArgs = args;
    if (frameId === null) {
      frameId = requestAnimationFrame(() => {
        frameId = null;
        fn(...lastArgs);
      });
    }
  };
}

const progressBar = document.querySelector('#reading-progress');

const updateProgress = rafThrottle(() => {
  const max = document.documentElement.scrollHeight - window.innerHeight;
  const ratio = max > 0 ? window.scrollY / max : 0;
  progressBar.value = Math.round(ratio * 100);
});

window.addEventListener('scroll', updateProgress, { passive: true });

Here #reading-progress is a <progress max="100"> element with an accessible label. The passive: true option tells the browser the handler will not call preventDefault(), so it never has to wait for the handler before scrolling.

Before throttling a scroll handler, check whether you need one. IntersectionObserver reports when elements enter or leave the viewport without any scroll listener, and CSS can handle many scroll effects. The CSS course covers scroll-driven animations.

Layout thrashing

The browser calculates layout (the size and position of every box) lazily. Changing a style marks layout as out of date, and the browser recalculates it before the next frame. But if your JavaScript reads a layout property, such as offsetHeight, getBoundingClientRect(), scrollTop or clientWidth, while layout is out of date, the browser must recalculate immediately to give you a correct answer. This is called forced synchronous layout.

Doing it in a loop, alternating a read and a write, forces a full layout on every iteration. That is layout thrashing:

JavaScript
const cards = document.querySelectorAll('.course-card');

// Slow: read, write, read, write...
for (const card of cards) {
  const width = card.offsetWidth;        // Read: forces layout.
  card.style.height = `${width * 0.6}px`; // Write: invalidates layout.
}

With 200 cards, that can mean 200 layout calculations in one task. The fix is to do all the reads first, then all the writes:

JavaScript
// Fast: all reads, then all writes.
const widths = Array.from(cards, (card) => card.offsetWidth);

cards.forEach((card, i) => {
  card.style.height = `${widths[i] * 0.6}px`;
});

Now layout is calculated once for the reads, and once more before the next frame for the writes. In this specific case CSS could do the job with no JavaScript at all (aspect-ratio: 5 / 3), which is the best fix of all. The CSS course explains which properties trigger layout, paint or only compositing.

When reads and writes come from different parts of the code, schedule writes together in a requestAnimationFrame callback so they all happen after the current reads, just before rendering.

Other ways to keep interactions responsive

  • Do less per event. Use event delegation (one listener on a container) instead of hundreds of listeners.
  • Update the DOM in one go. Build new items in a DocumentFragment or an array, then insert them with a single append or replaceChildren call.
  • Show feedback first. In a click handler, update the button or status text, then let the expensive work run after the browser has had a chance to paint, using the yielding technique from the event loop lesson.
  • Observe instead of polling. ResizeObserver reports element size changes without a resize listener and without forcing layout in a loop.
  • Measure. Record the interaction in the DevTools Performance panel. Long purple "Layout" blocks with a warning about forced reflow point to thrashing; long yellow scripting blocks point to heavy handlers.

web.dev describes Interaction to Next Paint (INP), a Core Web Vital that measures how quickly the page responds visually to clicks, taps and key presses. Heavy event handlers and layout thrashing are among the most common causes of poor INP.

Respect reduced motion

If a throttled handler drives animation, such as parallax or animated counters, check the user's preference and skip or simplify the effect:

JavaScript
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

if (!reduceMotion.matches) {
  window.addEventListener('scroll', updateParallax, { passive: true });
}

updateParallax stands for your own throttled handler.

Summary

  • Debounce runs a function once activity stops; use it for search, autosave and validation.
  • Throttle runs a function at most once per interval during activity; use it for scroll, drag and pointer tracking.
  • For visual updates, throttle to animation frames with requestAnimationFrame, and use passive scroll listeners.
  • Reading layout properties after writing styles forces synchronous layout; batch all reads before all writes.
  • Prefer observers and CSS where they can replace event handlers, and measure with the Performance panel.