"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.
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
// 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:
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.
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:
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());
Verification Steps
- Measure one redraw first. Wrap it in
performance.mark/measureas 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
windowresize 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.
Related
- Should You Debounce ResizeObserver Callbacks? — the same argument from the performance side
- Resizing a Canvas with ResizeObserver Without Blurring — the redraw this page is deciding how often to run
- Correlating Long Tasks with Observer Callbacks — measuring the redraw before optimising it
↑ Back to Responsive Canvas & Chart Resizing