Stabilizing a Jittery Force-Directed Graph

Your d3-force graph never comes to rest — nodes shiver in place, drift slowly forever, or bounce apart and snap back — and the fan keeps spinning because the tick loop never stops.

Jitter is almost always an energy-management problem, not a force-configuration one. This is a focused companion to hierarchical and network layouts in D3, and it deliberately takes a different angle from preventing memory leaks in D3 force graphs: that guide is about teardown and retained references, this one is about making the simulation converge and then stay still.

Diagnostic checklist

Simulation alpha decaying from hot to frozen A curve shows alpha falling from 1 toward alphaMin over successive ticks; while alpha is high nodes move a lot, as it approaches the minimum motion stops and the simulation is stopped. 1.0 min ticks → hot: nodes move alphaDecay drains energy alpha < min simulation.stop() tick loop ends no decay = alpha never reaches min = perpetual jitter
The simulation's alpha is its energy budget; alphaDecay drains it each tick until motion stops. If decay is zero or the graph keeps being re-heated, alpha never reaches the minimum and the jitter never ends.
Force-directed layout snapshot A 22-node graph after a real force simulation reaches equilibrium.
A settled snapshot of the force simulation behind a jittery force: repulsion, link springs and centring balanced after cooldown.

Broken vs fixed

// ❌ BROKEN: no cooldown control; re-heats on every update; tick never stops.
import { forceSimulation, forceLink, forceManyBody, forceCenter } from 'd3-force';

function buildBroken(nodes: GraphNode[], links: GraphLink[]) {
  const sim = forceSimulation(nodes)
    .force('link', forceLink(links))
    .force('charge', forceManyBody())
    .force('center', forceCenter(400, 300))
    .alphaDecay(0)          // never cools → alpha stays high → perpetual motion
    .velocityDecay(0.05);   // almost no damping → nodes overshoot and ring
  sim.on('tick', () => draw()); // repaints forever, even at rest
  return sim;
}
// ✅ FIXED: real cooldown, damping, and a hard stop once settled.
import {
  forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide,
  type Simulation,
} from 'd3-force';

interface GraphNode { id: string; x?: number; y?: number; fx?: number | null; fy?: number | null; }
interface GraphLink { source: string; target: string; }

function buildFixed(nodes: GraphNode[], links: GraphLink[]): Simulation<GraphNode, GraphLink> {
  const sim = forceSimulation<GraphNode>(nodes)
    .force('link', forceLink<GraphNode, GraphLink>(links).id((d) => d.id).distance(40))
    .force('charge', forceManyBody().strength(-120))
    .force('center', forceCenter(400, 300))
    .force('collide', forceCollide(12))  // prevents overlap-induced jitter
    .alphaDecay(0.0228)                  // default: alpha reaches alphaMin in ~300 ticks
    .velocityDecay(0.4);                 // damps oscillation without feeling sluggish

  sim.on('tick', () => draw());
  // PERF: stop the loop once the graph has cooled so the CPU goes idle.
  sim.on('end', () => sim.stop()); // 'end' fires when alpha < alphaMin
  return sim;
}

The three fixed values do three different jobs. alphaDecay(0.0228) (the default — the bug was setting it to 0) guarantees alpha falls below alphaMin in a finite number of ticks, at which point the 'end' event fires. velocityDecay(0.4) bleeds off velocity each tick so nodes ease into position instead of overshooting and ringing. And handling 'end' (or calling sim.stop()) ends the tick loop so a settled graph stops repainting.

Step-by-step fix

import { drag, type D3DragEvent } from 'd3-drag';
import { type Selection } from 'd3-selection';
import { type Simulation } from 'd3-force';

function attachDrag(
  selection: Selection<SVGCircleElement, GraphNode, SVGGElement, unknown>,
  sim: Simulation<GraphNode, GraphLink>,
): void {
  selection.call(
    drag<SVGCircleElement, GraphNode>()
      .on('start', (event: D3DragEvent<SVGCircleElement, GraphNode, GraphNode>, d) => {
        if (!event.active) sim.alphaTarget(0.3).restart(); // gently re-heat while dragging
        d.fx = d.x; d.fy = d.y;                            // pin to the pointer
      })
      .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
      .on('end', (event, d) => {
        if (!event.active) sim.alphaTarget(0);             // let it cool back down
        d.fx = null; d.fy = null;                          // A11Y: release so layout can settle
      }),
  );
}

Verification

// After the 'end' event, node velocities must be effectively zero.
sim.on('end', () => {
  const maxSpeed = Math.max(...sim.nodes().map((n) => Math.hypot(n.vx ?? 0, n.vy ?? 0)));
  console.assert(maxSpeed < 0.01, `graph not settled: max speed ${maxSpeed}`);
  console.assert(sim.alpha() <= sim.alphaMin(), 'alpha should be at or below alphaMin');
});

In DevTools, open the Performance panel and record a few seconds after the graph appears to settle: with the fix, scripting activity should drop to near zero once 'end' fires. If the flame chart keeps showing regular tick frames on an idle graph, the simulation was never stopped.

Why decay and damping are different knobs

The two decay parameters are easy to conflate because both make the graph “calm down”, but they act on different quantities and mixing them up leads to the wrong fix. alphaDecay governs the global cooling schedule: alpha is the simulation’s temperature, it multiplies the strength of every force each tick, and alphaDecay is how much it drops per tick. Set it too low and the whole graph stays hot and mobile for thousands of ticks; set it to zero and it never stops. velocityDecay, by contrast, is per-node friction applied to each node’s velocity every tick — it does not end the simulation, it just prevents nodes from carrying so much momentum that they shoot past their target and oscillate back. A graph with healthy alphaDecay but near-zero velocityDecay will cool eventually, but during cooling the nodes ring like a plucked string; a graph with heavy velocityDecay but zero alphaDecay moves sluggishly yet never stops, because the energy budget is never drained. You want both: a decay schedule that reaches alphaMin in a few hundred ticks, and enough velocity damping that the approach to rest is smooth rather than bouncy.

Edge cases & gotchas

  • Re-heating on every render. Calling sim.nodes(data).alpha(1).restart() on each React render or data poll resets the temperature to maximum, so a graph that had settled explodes back into motion. Diff the data and only re-heat on structural change.
  • forceCenter fighting forceManyBody. A strong centering force plus strong repulsion can leave nodes permanently creeping toward and away from the centre. Prefer positioning forces (forceX/forceY with low strength) over forceCenter for large graphs.
  • Pinned nodes never released. Setting fx/fy on drag but forgetting to null them on release leaves nodes frozen, so later layout changes cannot move them and the graph looks broken. Clear them unless a pin is intended.
  • Stopping too early. Calling sim.stop() right after construction freezes the graph before it has laid out. Stop only on the 'end' event or after a deliberate tick budget with sim.tick() in a loop.
  • Ticking off-screen. A simulation left running while its container is hidden still burns CPU and battery. Pause it with sim.stop() on an IntersectionObserver miss and restart on visibility.
  • Non-integer positions blurring marks. Force positions are fractional; rounding cx/cy at draw time removes shimmer on high-density graphs without affecting convergence.
Per-frame time vs the 16.7ms budget Measured-style frame durations with bars over budget marked in red. frame #ms/frame16.7ms budget (60fps)
Frame durations for a jittery force against the 16.7ms (60fps) budget — the red bars are the janky frames a fix must remove.

Frequently Asked Questions

Why does my force graph never stop moving?

Most often because alphaDecay was set to 0, so the simulation’s energy is never drained and alpha never reaches alphaMin. Restore the default alphaDecay(0.0228) (or any positive value) so the graph cools in a finite number of ticks, and listen for the 'end' event to call stop() once it has settled.

What is the difference between alphaDecay and velocityDecay?

alphaDecay controls the global cooling schedule — how fast alpha, the simulation’s temperature, drops each tick until it hits alphaMin and the simulation ends. velocityDecay is per-node friction that damps each node’s velocity so it eases into position instead of overshooting. One ends the simulation; the other smooths how nodes approach rest.

How do I let users drag nodes without breaking the layout?

On drag start and move, set the node’s fx and fy to the pointer position to pin it, and briefly raise alphaTarget so the rest of the graph reacts. On release, clear fx and fy back to null and set alphaTarget(0) so the node rejoins the simulation and the graph cools again. Leaving fx/fy set keeps the node frozen in place.