Interrupting D3 Transitions on Rapid Updates
A live feed pushes new data faster than your animations finish, so each update starts a fresh transition on top of the running one — elements jitter, jump backward, or freeze mid-flight.
This is a transition-lifecycle problem under Transition & Animation Sequences, the companion guide for orchestrating D3 animations. By default a new unnamed transition on a selection cancels the previous unnamed one — but only the part that shares attributes, and only if it is the same name. When updates arrive every few hundred milliseconds, transitions pile up, interpolate from stale start values, and the result reads as glitching. The cure is to interrupt deliberately and name your transitions so cancellation is predictable.
To reason about this correctly you have to know how D3 stores transitions on a node. Each element carries a hidden __transition registry keyed by transition name, and each entry moves through a small state machine: scheduled, then starting, then active, then ended (or interrupted). When you create a new transition with the same name on a node that already has one scheduled or active, D3 cancels the old one before installing the new — that is the documented “newer transition wins” rule. The catch is the qualifier same name. A bare .transition() uses the empty-string name, so two bare transitions on the same element do cancel each other; but a bare transition and a .transition('move') are different channels and run concurrently, each animating its own attributes. Most “my transitions glitch” reports are really “I have more than one channel writing the same attribute, and they are not cancelling because they have different names.”
The second half of the problem is start-value capture. A D3 transition does not read its start value when you define it; it reads it the moment it starts, which is on the next animation frame. So if a transition is already mid-flight and you stack another one (different name, no interrupt), the new transition captures the current animated value as its start and interpolates from there toward a new target — while the old transition is still pushing toward its own target on the same attribute. The two writes alternate frame to frame and the element visibly oscillates. Interrupting the old channel before starting the new one removes the competing writer, so the new transition has a single, stable start value and moves monotonically to its target.
Diagnostic checklist
How interrupt and naming interact
Broken vs fixed
// ❌ BROKEN: every update stacks a new transition; rapid data jitters.
import { select } from 'd3-selection';
function update(values: Array<{ id: string; y: number }>): void {
select('g#line')
.selectAll<SVGCircleElement, { id: string; y: number }>('circle')
.data(values, (d) => d.id)
.join('circle')
.transition() // unnamed; on fast updates these overlap and interpolate from mid-flight
.duration(500)
.attr('cy', (d) => d.y);
}
// ✅ FIXED: interrupt the named transition, then start a fresh one.
import { select } from 'd3-selection';
function update(values: Array<{ id: string; y: number }>): void {
const sel = select('g#line')
.selectAll<SVGCircleElement, { id: string; y: number }>('circle')
.data(values, (d) => d.id)
.join('circle');
// PERF: interrupt only the 'pos' transition so unrelated animations keep running.
sel.interrupt('pos');
sel
.transition('pos') // named so re-entry cancels the same channel predictably
.duration(Math.min(500, 200)) // keep duration <= update interval to avoid backlog
.attr('cy', (d) => d.y);
// A11Y: announce significant data changes via an aria-live region, not the animation alone.
}
The fix names the transition and interrupts that exact name before starting a new one, so the next transition begins from the element’s current value rather than racing the previous animation.
Capping the duration is the other half of staying ahead of the feed, and it is easy to overlook. Even with clean interruption, if your transition runs 500ms but data arrives every 200ms, each update interrupts a transition that was only 40% of the way to its last target — the element never reaches any target before being yanked toward the next, so it lags the data and feels mushy. Keeping the duration at or below the inter-update interval means each animation has a chance to substantially complete before the next begins, so the element tracks the data tightly. When the feed is faster than any animation you would want to watch, stop animating per packet entirely: coalesce incoming packets with a throttle or a requestAnimationFrame batch and run one transition per frame against the latest value. That converts a backlog of doomed transitions into a single, always-current one.
Step-by-step fix
Verification
Confirm only one transition of a given name runs per element at a time:
import { select } from 'd3-selection';
const node = select<SVGCircleElement, unknown>('g#line circle').node();
if (node) {
// d3 stores active transitions under a __transition key on the node.
const active = (node as unknown as { __transition?: Record<string, unknown> }).__transition;
const count = active ? Object.keys(active).filter((k) => k !== 'active').length : 0;
console.assert(count <= 1, `expected <=1 queued transition, found ${count}`);
}
Visually, in a Performance trace during a burst of updates the cy (or transform) values should move monotonically toward each target instead of oscillating; oscillation means transitions are still overlapping.
Edge cases and gotchas
interrupt()only cancels the named channel. Unnamed transitions on the same attribute keep running; standardize on names across the codebase.- Interrupt fires the
interruptevent, notend. If cleanup is wired to.on('end', ...), it will not run on interruption; also handle.on('interrupt', ...)to avoid leaked state. - GSAP/raf-driven animations. If you mix D3 transitions with another animation engine on the same property, D3’s interrupt cannot stop the other engine; pick one owner per attribute. See raf vs GSAP for data transitions.
interruptdoes not bubble to descendants by default. Callingselection.interrupt('name')stops transitions on the selected elements only. If you animate a parent<g>and its children independently, interrupting the parent leaves child transitions running; useselection.selectAll('*').interrupt('name')to reach descendants, or interrupt each level explicitly.- Tween closures capturing stale data. A tween built with
.attrTweencloses over the datum at definition time. If you reuse a node across rebinds without rebuilding the tween, the interpolator can animate toward an outdated target. Rebuild tweens on each update, or readthis.__data__inside the tween factory so the current datum drives the interpolation. - Interrupt during a chained transition. Interrupting a transition that has queued successors (via
transition.transition()) cancels the whole chain, not just the active segment. If you rely on a chained sequence completing, guard the interrupt so it only fires when a genuinely newer update has arrived, rather than on every render tick.
Related
- Transition & Animation Sequences — the parent guide for D3 animation orchestration.
- Chaining D3 Transitions Without Animation Glitches — sequencing transitions cleanly.
- Animating Enter and Exit Selections Smoothly — the join-side companion to interruption.