"Debounce the resize handler" is advice inherited from the window resize event, where it was correct. Applied to ResizeObserver it usually makes a chart feel worse while removing no work at all.

Problem / Scenario Context

A dashboard has a draggable split pane. Dragging it redraws a chart, and the drag feels sticky, so a 150 ms trailing debounce is added. The stickiness goes away — because the chart now redraws once when the drag stops, and during the drag the pane is empty of any updated content. What was a chart lagging slightly behind the pointer is now a chart that does not move at all until you let go.

The team's instinct was right that the redraw was too expensive. The remedy was wrong, because the number of redraws was never the problem: ResizeObserver was already delivering one entry per frame. What cost too much was the work inside each one.

Mechanics Explanation

The window resize event has no rate limit; a drag can fire it dozens of times per frame, which is why debouncing it is standard. ResizeObserver is different by specification: observations are delivered once per frame, at the end of the layout step, carrying the final sizes for that frame. There is no burst to collapse.

That leaves three genuinely different interventions, often conflated:

  • Coalescing into an animation frame. Groups several observers' callbacks, or several entries, into one write pass. Adds no latency because the frame was going to happen anyway.
  • Skipping unchanged work. Comparing the new backing size against the current one and returning early. Removes real work at zero cost.
  • Debouncing. Deferring until changes stop. Removes work only when the work is genuinely more expensive than one frame, and always adds visible latency.

The right question is not "how do I reduce callbacks" but "what does one callback cost". If a redraw takes 3 ms, sixty per second is fine. If it takes 40 ms, no amount of debouncing makes the drag smooth — it merely hides the cost until the end.

Three Strategies Across One Two-Second DragThree tracks over a two-second drag. Unlimited delivery produces one redraw per frame, following the pointer exactly. Frame coalescing produces the same count, because the observer already delivers once per frame, so it changes nothing here. A 150 millisecond trailing debounce produces a single redraw after the drag ends, leaving the chart stale for the whole gesture.A two-second drag of a split paneas deliveredone per frame — the chart tracks the pointerrAF coalescedidentical count — there was no burst to collapse150ms debounce— nothing at all for the whole drag —one redraw, after releaseThe debounce did not make the redraw cheaper. It made the chart absent while the reader was looking at it.

Comparison Table: What Each Technique Actually Does

Technique Callbacks removed Latency added Use when
Nothing none redraw under ~4 ms
Early return on unchanged size the no-op ones none always — it is free
rAF coalescing across multiple observers none several observers write in one frame
Trailing debounce most, during a gesture the full wait redraw over ~30 ms, or it triggers a fetch
Leading + trailing debounce the middle ones none at the start a compromise for expensive redraws

The second row is the one to reach for first. A container that grows in height only should not clear and redraw a chart whose width did not change, and that check costs one comparison.

Minimal Reproducible Example

TypeScript
// Removes nothing, adds 150ms of staleness
let timer: number | undefined;
const ro = new ResizeObserver((entries) => {
  clearTimeout(timer);
  timer = window.setTimeout(() => redraw(entries[0]), 150);
});

Production-Safe Solution

Start with the free wins, and only then consider deferring:

TypeScript
let scheduled = false;
let latest: ResizeObserverEntry | null = null;
let lastW = 0, lastH = 0;

const ro = new ResizeObserver((entries) => {
  latest = entries[entries.length - 1];
  if (scheduled) return;
  scheduled = true;
  requestAnimationFrame(() => {
    scheduled = false;
    const entry = latest!;
    const { w, h, r } = backing(entry);
    if (w === 0 || h === 0) return;
    if (w === lastW && h === lastH) return;      // nothing changed — the free win
    lastW = w; lastH = h;
    canvas.width = w; canvas.height = h;
    ctx.setTransform(r, 0, 0, r, 0, 0);
    draw(ctx, entry.contentRect.width, entry.contentRect.height);
  });
});

If the redraw is genuinely expensive, the answer is a two-tier render rather than a debounce: draw something cheap every frame, and the full version once movement stops.

TypeScript
let settle: number | undefined;

function onResize(entry: ResizeObserverEntry): void {
  resizeBackingStore(entry);
  drawCheap(ctx, entry);                    // axes, frame, a downsampled series — every frame
  clearTimeout(settle);
  settle = window.setTimeout(() => drawFull(ctx, entry), 120);   // detail, once
}

That keeps the chart present and responsive during the gesture and pays the expensive cost once, which is what the debounce was reaching for without giving up the feedback.

A true debounce is right in one case: when the "redraw" is not a redraw at all but a network request — a chart that refetches at a new resolution, or a layout that asks the server for a different dataset. There, the intermediate values are genuinely worthless and the request is expensive. Use a trailing debounce and flush it on teardown, or the last size is silently dropped:

TypeScript
function trailing<T extends unknown[]>(fn: (...a: T) => void, wait: number) {
  let t: number | undefined, last: T | null = null;
  const run = () => { if (last) { fn(...last); last = null; } };
  const wrapped = (...args: T) => { last = args; clearTimeout(t); t = window.setTimeout(run, wait); };
  wrapped.flush = () => { clearTimeout(t); run(); };
  return wrapped;
}

const refetch = trailing((w: number) => fetchSeries(w), 250);
// on teardown — otherwise the final size never reaches the server
onDestroy(() => refetch.flush());

Cheap Every Frame, Expensive OnceA drag timeline with two render tracks. The cheap render — axes, frame and a downsampled series — runs on every frame so the chart never disappears. The full render, with all detail, runs once after movement stops. Together they give continuous feedback at a fraction of the cost of drawing everything sixty times a second.Two tiers instead of one deferralcheap tier~2ms each — axes, frame, downsampled seriesfull tier— waits for movement to stop —~40ms, onceThe reader always sees a chartand the expensive path runs once per gesture instead of sixty times — or, with a plain debounce, once with nothing before it.

Measure Before ChoosingA decision threshold based on the measured cost of one redraw. Under four milliseconds, no limiting is needed. Between four and thirty, coalesce into an animation frame and skip unchanged sizes. Above thirty, split into a cheap tier drawn every frame and a full tier drawn once movement stops. A debounce appears only where the work is a network request.Pick from the measured cost of one redraw, not from instinctunder 4msDo nothing. The observer already delivers once per frame.4 – 30msrAF coalescing plus an early return on unchanged size. Both are free.over 30ms, or a network requestTwo-tier render — or, for a request, a trailing debounce with a teardown flush.

Verification Steps

  • Measure one redraw first. Wrap it in performance.mark/measure as described in correlating long tasks with observer callbacks. If it is under 4 ms, do nothing else.
  • Count callbacks during a drag. If it is roughly one per frame, there is no burst and nothing for a debounce to collapse.
  • Check the early return is firing by logging skipped callbacks; on a height-only drag it should skip most of them.
  • Test the teardown flush by unmounting mid-drag and confirming the final size still reaches the server.

Common Mistakes to Avoid

  • Porting window resize advice unchanged. The rate limit that made it necessary does not exist here.
  • Debouncing without a flush. The last, and most correct, size is the one thrown away.
  • Debouncing a pure redraw. It trades responsiveness for nothing; make the redraw cheaper instead.
  • Using a leading-edge-only debounce. The chart then reflects the size at the start of the drag, which is the one value guaranteed to be wrong by the end.

FAQ

Does ResizeObserver need debouncing at all?

Usually not. It delivers at most once per frame by specification, carrying the final sizes for that frame, so there is no burst to collapse. A debounce removes no work in the common case and adds the full wait as visible staleness.

When is a debounce genuinely the right tool?

When the work triggered by a resize is not drawing but something genuinely expensive and discardable — a network request for a different dataset, or a server-side render at a new width. There the intermediate values are worthless and the cost is real, so waiting for the size to settle is correct.

What should I do instead if the redraw is slow?

Make the redraw cheaper, or split it into two tiers: a cheap render every frame so the chart stays visible, and the full-detail render once movement stops. That keeps feedback continuous and pays the expensive cost once per gesture.

Why does my debounced resize sometimes miss the final size?

Because the pending call is cancelled when the component unmounts or the observer disconnects. A debounce is a promise to act later, so the teardown path has to decide explicitly whether to flush the pending call or discard it — leaving it implicit drops the last value.


↑ Back to Responsive Canvas & Chart Resizing