Implement a Throttled Resize Handler in JavaScript

Intermediate12 min interview
Skills tested:
Implementing timestamp-based throttle using Date.now() without setTimeoutUsing requestAnimationFrame as an alternative to timestamp throttle for layout-synchronized resize handlingUnderstanding why an unthrottled resize listener causes layout thrashingAdding the passive option to event listeners that never call preventDefaultCleaning up event listeners when the handler is no longer needed

Advertisement

🧩 Scenario

In a real codebase, custom chart libraries, canvas renderers, and layout components need to respond to window resize events to recalculate dimensions and redraw. An unthrottled listener that recalculates and repaints on every resize pixel causes jank because layout reads and writes interleave, triggering forced synchronous layouts. The fix is to throttle the handler so it fires at most once per animation frame or N milliseconds.

Architecture Walkthrough

Timestamp-Based Throttle

A timestamp-based throttle uses Date.now() to track when the wrapped function last executed. On every call, it computes the elapsed time since the last execution. If elapsed time is less than the configured delay, it returns immediately. If elapsed time equals or exceeds the delay, it records the new timestamp and calls the function.

This approach has a critical advantage over setTimeout-based throttle for resize handlers: it does not queue a deferred call. A setTimeout throttle schedules a callback to fire after the delay elapses. If resize events arrive at 60 per second and the delay is 100ms, approximately 6 events are queued. After resize ends, all 6 queued callbacks fire in rapid succession. The timestamp approach has no queue: calls that arrive during the delay window are simply dropped.

requestAnimationFrame Throttle

requestAnimationFrame (rAF) provides a simpler throttle that aligns exactly with the browser's paint cycle (typically 60fps on a 60Hz display). You set a flag when a rAF is pending; if the flag is set, return immediately. When the rAF fires, clear the flag and call the handler. Subsequent resize events during the same frame are dropped.

Because rAF callbacks run just before the browser paints, any layout writes performed inside the callback are batched with the current paint pass. This is optimal for handlers that update CSS or DOM dimensions in response to resize.

Passive Listeners and Cleanup

The resize event on window does not support preventDefault(). Adding { passive: true } tells the browser this and allows it to skip waiting for the handler to complete before beginning scroll or paint processing. Always add passive: true for resize and scroll listeners.

Cleanup is important when the handler is used in a component or module with a teardown phase. Store the exact same function reference that was passed to addEventListener and pass it to removeEventListener. Arrow functions defined inline cannot be removed because each definition creates a new reference.


Key Code Explained

// Option 1: Timestamp-based throttle — no queued callbacks
function createThrottledResizeHandler(handler, delay = 100) {
  let lastRan = 0;

  const throttled = () => {
    const now = Date.now();
    if (now - lastRan < delay) return; // still within the throttle window
    lastRan = now;
    handler(window.innerWidth, window.innerHeight);
  };

  window.addEventListener('resize', throttled, { passive: true });

  return {
    destroy: () => window.removeEventListener('resize', throttled),
  };
}

const resizeHandler = createThrottledResizeHandler((width, height) => {
  recalculateLayout(width, height);
  redrawChart(width, height);
}, 100);

// On teardown:
resizeHandler.destroy();


// Option 2: requestAnimationFrame — frame-aligned, no setTimeout required
function createRafResizeHandler(handler) {
  let rafPending = false;

  const onResize = () => {
    if (rafPending) return; // rAF already queued for this frame
    rafPending = true;

    requestAnimationFrame(() => {
      rafPending = false;
      handler(window.innerWidth, window.innerHeight);
    });
  };

  window.addEventListener('resize', onResize, { passive: true });

  return {
    destroy: () => window.removeEventListener('resize', onResize),
  };
}


// Option 3: ResizeObserver — preferred for observing a specific element
function observeElementResize(element, handler) {
  const observer = new ResizeObserver((entries) => {
    const entry = entries[0];
    const { width, height } = entry.contentRect;
    handler(width, height);
  });

  observer.observe(element);

  return {
    destroy: () => observer.disconnect(),
  };
}

// React integration
function useWindowSize(delay = 100) {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const { destroy } = createThrottledResizeHandler((width, height) => {
      setSize({ width, height });
    }, delay);

    return destroy;
  }, [delay]);

  return size;
}

The ResizeObserver in option 3 is the modern replacement for listening to window resize events when the goal is responding to a specific element's size. Unlike a window resize listener, it fires even when the element's size changes due to CSS changes, parent layout shifts, or content changes, not only on viewport resize.


Tradeoffs

ApproachFires rateQueue buildupLayout alignmentBest for
Unthrottled listenerEvery resize pixelNoneNoneNothing (causes jank)
Timestamp throttleAt most once per delayNoneNoGeneral recalculation tasks
rAF throttleAt most once per frameNoneYes (pre-paint)Canvas renders, DOM layout updates
ResizeObserverWhen element changesBrowser-managedYesObserving a specific element

What Interviewers Actually Check

  • Whether you can implement timestamp-based throttle without relying on setTimeout
  • Whether you know requestAnimationFrame as an alternative aligned with the paint cycle
  • Whether you can explain the queue problem with setTimeout-based throttle on a continuous event
  • Whether you know to add passive: true to scroll and resize listeners
  • Whether you know ResizeObserver as the modern API for element-level resize detection

Follow-Up Questions

  1. How does ResizeObserver differ from a MutationObserver, and when would you use each?
  2. If your resize handler performs a canvas drawImage call, why is rAF throttle more correct than timestamp throttle?
  3. How would you implement a useElementSize hook that uses ResizeObserver inside React?
  4. What is the contentBoxSize vs borderBoxSize vs devicePixelContentBoxSize on a ResizeObserver entry?
  5. How would you handle a case where the resize handler itself takes longer than a single 16ms animation frame?

Common Candidate Mistakes

  • Using a setTimeout-based throttle and not knowing it queues callbacks that fire in a burst after resize ends
  • Not adding { passive: true } to the resize listener, leaving the browser waiting unnecessarily
  • Defining the handler inline as an arrow function and then calling removeEventListener with a different arrow function reference, which does nothing
  • Reading getBoundingClientRect and writing CSS dimensions in alternating order inside the handler, causing forced synchronous layouts
  • Using debounce when the use case (live chart resize) requires continuous feedback during the resize gesture

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you implement timestamp-based throttle from memory without using setTimeout?
  • Can you explain why requestAnimationFrame is well-suited for resize handlers that update the layout?
  • Can you describe what the passive option does on an event listener and when to use it?
  • Can you explain when to use throttle vs debounce for a resize event?
  • Can you write a cleanup function that correctly removes the resize listener?

Summary

An unthrottled window resize listener fires dozens of times per second during a resize gesture. If the handler performs layout reads or DOM writes on each call, it causes forced synchronous layouts and visible jank.

Timestamp-based throttle is the most predictable fix: compute elapsed time on each call and skip if within the delay window. Unlike setTimeout-based throttle, it does not queue deferred calls, so no burst of stale callbacks fires after the resize ends. For handlers that update the DOM or canvas, requestAnimationFrame is a better fit: it fires at most once per animation frame, and callbacks run just before painting, allowing layout writes to batch with the current frame.

For component-level resize detection, ResizeObserver is the modern standard. It observes an element directly rather than the window, fires only when that element's dimensions change, handles browser-level throttling, and works correctly with CSS-triggered size changes that have nothing to do with the viewport size.

Frequently Asked Questions

What is the difference between throttle and debounce for a resize handler?

Throttle fires at a maximum frequency (e.g., once per 100ms) so layout updates happen continuously during resize. Debounce fires only after the user stops resizing. Use throttle when you need live feedback, debounce when you only care about the final size.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement