Debouncing Brush Events on Large Selections
Dragging a brush across a 100,000-point scatter turns the whole page to treacle: the brush event fires dozens of times a second and each fire re-scans and re-renders the entire dataset, so the selection rectangle stutters far behind the pointer.
This is part of brushing and linked views in D3, focused on the performance failure that appears once the selected set is large. The fix combines two ideas: rate-limit the handler so it runs at most once per frame, and split the work into a cheap live preview and an expensive committed result. The rate-limiting choice — throttle versus debounce — is the same trade covered generally in choosing debounce vs throttle for chart interactions, applied here to d3-brush.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: full O(n) filter + re-render on every brush event.
import { brushX, D3BrushEvent } from 'd3-brush';
interface Pt { x: number; sel: boolean; }
const points: Pt[] = load(100_000);
const b = brushX<undefined>().on('brush', (event: D3BrushEvent<undefined>) => {
const s = event.selection as [number, number] | null;
if (!s) return;
const [x0, x1] = s;
for (const p of points) p.sel = p.x >= x0 && p.x <= x1; // 100k scan…
renderAll(points); // …then a full re-render, dozens of times a second
});
// ✅ FIXED: coalesce with rAF, preview cheaply, commit once on end.
import { brushX, D3BrushEvent } from 'd3-brush';
import { ScaleLinear } from 'd3-scale';
interface Pt { x: number; sel: boolean; }
function makeBrush(
x: ScaleLinear<number, number>,
points: Pt[],
previewOverlay: (px0: number, px1: number) => void, // cheap: move a rect
commit: (lo: number, hi: number) => void, // expensive: re-aggregate
): void {
let rafId: number | null = null;
let pending: [number, number] | null = null;
function flush(): void {
rafId = null;
if (pending === null) return;
// PERF: preview is O(1) — just reposition the selection overlay, no data scan.
previewOverlay(pending[0], pending[1]);
}
const b = brushX<undefined>()
.on('brush', (event: D3BrushEvent<undefined>) => {
pending = event.selection as [number, number] | null;
if (rafId === null) rafId = requestAnimationFrame(flush); // one update/frame
})
.on('end', (event: D3BrushEvent<undefined>) => {
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; }
const s = event.selection as [number, number] | null;
if (s === null) { commit(-Infinity, Infinity); return; }
// The single expensive pass happens exactly once, when the drag settles.
commit(x.invert(s[0]), x.invert(s[1]));
// A11Y: announce the committed range and resulting count here, not per event.
});
attach(b);
}
declare function attach(b: unknown): void;
The two changes are independent and both matter. Coalescing with requestAnimationFrame guarantees the handler body runs at most once per frame no matter how fast the pointer moves, which caps the frequency. Splitting preview from commit caps the cost per run: during the drag you only reposition the overlay rectangle (O(1)), and the O(n) scan-and-aggregate happens exactly once, on end. Either alone helps; together they keep a 100k-point brush smooth.
Step-by-step fix
// A generic rAF throttle you can reuse for any high-frequency chart handler.
function rafThrottle<A extends unknown[]>(fn: (...args: A) => void): (...args: A) => void {
let id: number | null = null;
let latest: A | null = null;
return (...args: A): void => {
latest = args;
if (id !== null) return; // a frame is already queued
id = requestAnimationFrame(() => {
id = null;
if (latest) fn(...latest); // run with the freshest args only
});
};
}
Verification
// Assert the rAF throttle collapses a burst into one call per frame.
let calls = 0;
const throttled = rafThrottle<[number]>(() => { calls++; });
for (let i = 0; i < 50; i++) throttled(i); // 50 rapid invocations
requestAnimationFrame(() => {
console.assert(calls === 1, `expected 1 call this frame, saw ${calls}`);
});
In DevTools, record a Performance trace while dragging the brush and confirm the brush handler now shows one task per frame instead of a dense cluster, and that the long Recalculate Style/Scripting band only appears once, at the moment you release. Enable “Paint flashing” in the Rendering panel to verify only the overlay repaints during the drag, not the whole point layer.
Debounce, throttle, or rAF-coalesce
The three techniques are cousins and the right pick depends on what “smooth” means for a brush. A time-based throttle (run at most once per N milliseconds) works but couples your frame rate to an arbitrary constant; pick 16ms and you approximate one frame, but you are guessing. Debounce (wait until events stop, then run once) is wrong for the live preview because it makes the selection rectangle lag until the pointer pauses — though debounce is exactly right for the commit if you want the expensive filter to run continuously-but-rarely rather than only on end. The requestAnimationFrame coalesce used above is usually the best default for the preview because it locks the update to the display’s actual refresh cadence: one update per painted frame, no more, no fewer, and it automatically backs off when the tab is hidden because rAF stops firing. Reach for a time debounce on the commit when the interaction has no natural end — for example a brush whose extent is being programmatically animated — and for the rAF coalesce whenever a human is dragging.
Escalating to Canvas
Rate-limiting caps how often you pay the cost, but at extreme cardinality the cost itself is the problem: repainting 100,000 SVG circles even once, on end, can blow the frame budget because each is a DOM node the browser must lay out and paint. When the preview overlay and the once-per-drag commit still stutter, the marks themselves need to leave the DOM. Render the dense point layer to a Canvas — one clearRect and a tight fill loop repaint all 100k points in a couple of milliseconds — and keep only the brush’s own <g> and the selection overlay in SVG for their event handling and accessibility semantics. The brush geometry, the inversion, and the rAF coalesce are all unchanged; only the paint target moves. This hybrid, dense data on Canvas and interaction chrome on SVG, is the standard endgame for large brushable views and is why the rate-limiting work above composes cleanly with a rendering-engine switch rather than being thrown away by it.
Edge cases & gotchas
- A queued frame outliving the commit. If a
brush-scheduled frame fires afterend, it repaints a stale preview over the committed result. AlwayscancelAnimationFramein theendhandler. - rAF starving a hidden tab.
requestAnimationFramedoes not fire in a background tab, so a brush programmatically moved while hidden will not preview; commit onendstill fires, which is the behaviour you want. - Debouncing the preview by mistake. A debounced preview makes the rectangle feel detached from the pointer; keep the preview on rAF and reserve debounce for the commit if you use one at all.
- Allocating inside the handler. Building a fresh filtered array on every fire defeats the point; mutate a reused typed array or a boolean flag field and let the commit produce the final array once.
- Layout thrashing in the preview. Reading
getBoundingClientRector a computed style inside the handler forces synchronous reflow every frame; cache geometry outside the handler — the same discipline as reducing layout thrashing in real-time charts. - Coarse count previews. Showing an exact selected count on every frame forces the scan you were trying to avoid; show an approximate count from the bounding box during the drag and the exact count on commit.
Frequently Asked Questions
Why does my D3 brush lag over a large dataset?
Because the brush event fires many times a second and, if the handler re-scans and re-renders the whole dataset each time, you multiply an O(n) pass by the event frequency. Coalesce the handler to one run per frame with requestAnimationFrame and keep the per-frame work O(1) — just move the selection overlay — deferring the full filter to the end event so the expensive pass happens exactly once.
Should I debounce or throttle a d3-brush handler?
For the live selection rectangle, coalesce with requestAnimationFrame, which runs once per painted frame and stays locked to the display refresh rate. Debouncing is wrong for the preview because it waits for the pointer to pause and makes the rectangle lag. Debounce is appropriate only for the expensive commit when the interaction has no natural end event to hang it on.
How do I keep the exact filter result correct while previewing cheaply?
Split the work: during the drag, only reposition the overlay rectangle, which is constant-time and gives instant feedback; on the end event, run the full scale.invert and O(n) filter a single time to produce the exact selected set. Cancel any pending animation frame in the end handler so a queued preview cannot overwrite the committed result.
Related
- Brushing and linked views in D3 — the parent topic on coordinated selection.
- Filtering a scatterplot with a D3 brush — the selection logic this optimizes.
- Choosing debounce vs throttle for chart interactions — the general rate-limiting decision.
- Reducing layout thrashing in real-time charts — avoiding forced reflow inside handlers.