Making a D3 Chart Redraw on Container Resize

Your D3 chart renders perfectly on load, then stays frozen at its initial size when the surrounding container grows, shrinks, or reflows.

This is the most common responsiveness bug in D3 code, and it traces back to a single cause: the chart measured its container exactly once. The fix lives in the broader Responsive Chart Scaling with ResizeObserver & viewBox guide; this page walks the specific wiring, the rescale-vs-redraw choice, and the teardown that stops the observer from leaking.

Diagnostic checklist

Rescale vs redraw

Two different responses are possible when the container changes:

  • Rescale keeps the existing DOM nodes and only updates the viewBox (free, but stretches text and freezes tick density).
  • Redraw recomputes scale ranges, regenerates axes, and re-binds data so labels and tick counts adapt to the new size.

Most charts want a hybrid: let the SVG viewBox absorb fluid scaling, and run a debounced redraw for axes and legends.

The distinction matters because the two failure modes look different. A chart that only ever rescaled will show stretched, distorted text and tick marks crammed together or spread too far apart, because the number of ticks was fixed at the original width. A chart that does a full redraw on every resize entry will be correct but janky, because it rebuilds geometry dozens of times during a single drag. The production answer is to recompute scale ranges and regenerate axes — so labels stay legible and tick density adapts — but to do that work on a debounced trailing call rather than on every intermediate size. Between debounced redraws, the viewBox keeps the surface visually tracking the container so there is no blank gap during the drag.

One more decision hides inside “redraw”: whether to recompute the scale range only, or the domain too. The range almost always changes with size, because it maps data to pixels and the pixel extent just changed. The domain — the data extent the chart shows — usually should not change on resize; a window getting wider should not suddenly reveal more data. Keep the domain fixed across resizes and recompute only the range, unless your design explicitly wants size-driven detail (for example, showing more time-series history when there is more horizontal room).

Rescale versus redraw Rescaling updates only the viewBox while redraw recomputes scale ranges and axes from the new size. Rescale Redraw update viewBox only no DOM churn text stretches recompute ranges regenerate axes labels stay legible
Rescaling is free but distorts text; redraw recomputes scales so ticks and labels adapt to the new size.

Broken vs fixed

// ❌ BROKEN: measures the container once, then never again.
import * as d3 from "d3";

function chart(container: HTMLElement, data: number[]) {
  const width = container.clientWidth;   // read ONCE at init
  const height = container.clientHeight;
  const x = d3.scaleLinear().domain([0, data.length]).range([0, width]);
  const svg = d3.select(container).append("svg")
    .attr("width", width)    // fixed pixel attrs pin the size
    .attr("height", height);
  // ...draw... container can resize all it wants; nothing recomputes.
}
// ✅ FIXED: observe the container, debounce, recompute on each settle.
import * as d3 from "d3";

function chart(container: HTMLElement, data: number[]) {
  const svg = d3.select(container).append("svg")
    .attr("viewBox", "0 0 0 0")          // set per-size below; no width/height attrs
    .attr("role", "img");                 // A11Y: accessible role for the chart
  const x = d3.scaleLinear().domain([0, data.length]);
  const xAxisG = svg.append("g").attr("class", "x-axis");
  const path = svg.append("path").attr("fill", "none").attr("stroke", "#2563eb").attr("stroke-width", 2);

  function draw(w: number, h: number) {
    svg.attr("viewBox", `0 0 ${w} ${h}`);
    x.range([40, w - 16]);                // recompute range from new width
    const line = d3.line<number>().x((_, i) => x(i)).y((d) => h - 16 - d);
    path.datum(data).attr("d", line);     // PERF: one path write, not one node per point
    xAxisG.attr("transform", `translate(0,${h - 16})`).call(d3.axisBottom(x).ticks(Math.max(2, w / 80)));
  }

  let t: number | undefined;
  const ro = new ResizeObserver((entries) => {
    const cr = entries[entries.length - 1].contentRect;
    if (t !== undefined) clearTimeout(t);
    t = window.setTimeout(() => draw(cr.width, cr.height), 120); // PERF: debounce burst → 1 redraw
  });
  ro.observe(container);

  return () => { ro.disconnect(); if (t !== undefined) clearTimeout(t); }; // teardown
}

Step-by-step fix

Verification

Confirm the wiring without guessing:

// In DevTools, resize the container and assert the viewBox tracked it.
const svg = container.querySelector("svg")!;
const before = svg.getAttribute("viewBox");
container.style.width = "640px";
setTimeout(() => {
  console.assert(svg.getAttribute("viewBox") !== before, "viewBox did not update on resize");
}, 200); // wait past the 120ms debounce

Open the Performance panel, drag-resize the panel, and confirm a single layout+paint flurry per settle rather than continuous redraws. In the Memory panel, mount and unmount the chart a dozen times; the ResizeObserver count and detached-node count should return to baseline if teardown runs.

A quick way to prove the debounce is working is to count redraws. Increment a counter inside draw() and log it; during a one-second continuous drag you should see a single increment after you release, not one per frame. If you see a burst, the debounce timer is being recreated incorrectly or you are calling draw() directly from the observer callback in addition to the timer. Conversely, if draw() never fires at all, the observer is probably attached to the SVG (which you may be resizing) rather than to its container, or the container is 0×0 because it has not been laid out yet.

Why window.resize is the wrong tool

It is worth dwelling on the most common version of this bug, which is reaching for window.addEventListener("resize", ...) and assuming it covers container resizing. It does not. The resize event fires only when the browser viewport changes — the user drags the window edge, rotates a device, or opens devtools docked to the side. A dashboard panel almost never resizes for those reasons. It resizes because a sibling card grew, a flex container redistributed space, a CSS grid track changed, a collapsible navigation rail toggled, or the data inside another widget pushed the layout. None of those fire window.resize, so a chart wired to the viewport event sits frozen through exactly the resizes that actually happen in a real app. ResizeObserver was added to the platform specifically to observe element-level box changes regardless of their cause, which is why it is the correct primitive here. If you still support a browser without it, a ResizeObserver polyfill is small and behaves identically; do not fall back to a viewport listener, because it will be silently wrong.

There is also a performance reason to prefer the observer. A window.resize handler that reads container.clientWidth forces a synchronous layout every time it runs, and during a fast viewport drag it runs continuously. The ResizeObserver delivers already-computed sizes in a post-layout phase, so reading them is free, and it only fires when a box genuinely changed rather than on a timer or on every mousemove. The combination of correctness and cheap reads is why every modern D3 responsiveness recipe is built on it.

Edge cases & gotchas

  • React/Vue double-mount. Strict Mode (React) and HMR mount, unmount, then remount components. If teardown does not run, you accumulate observers all firing into stale closures. Always disconnect in the cleanup of useEffect/onUnmounted.
  • Zero-size on first paint. If the container is display: none or not yet laid out when you observe it, the first contentRect is 0×0. Guard draw() against zero dimensions and let the observer fire again once it has real size.
  • Hidden tabs. Containers in inactive tab panels report stale sizes; re-observe or force a redraw when the tab becomes visible.
  • Transitions in flight. If a D3 transition is animating when a resize redraw fires, the two can fight, leaving marks stranded mid-animation. Interrupt active transitions with selection.interrupt() at the top of draw() before re-binding, so the new layout starts from a clean state.
  • Loop warning from self-observation. If your draw() changes the size of the very element you observe (for example, by appending content that grows the container), you will see the “ResizeObserver loop” warning and possibly a flicker. Observe an outer wrapper whose size you never mutate, and resize only the inner SVG.

Frequently Asked Questions

Why does my D3 chart not resize even though the page is responsive?

Almost always because it measured the container once at init and never observed it again, or because it listens to the window resize event, which does not fire for container-level layout changes. Wire a ResizeObserver to the container and recompute scale ranges in the callback.

Should I update the viewBox or recompute scales on resize?

Recompute scales for anything with axes or labels, so tick density and text stay legible at the new size. Pure viewBox rescaling is free but stretches text and freezes the original tick count, so use it only for the drawing surface while a debounced redraw handles axes and legends.

Do I have to disconnect the ResizeObserver?

Yes. On unmount, call disconnect() and clear any pending debounce timer. Skipping this leaks the observer and the chart subtree it references, which accumulates across mount and unmount cycles, especially under React Strict Mode or hot module replacement.