Skip to content

Performance

Nodus is built for scale. The Rust/WebAssembly core holds the graph and runs layouts off the main thread, while the GPU renderer draws everything with instanced rendering — one draw call for all nodes, one for all edges. The result: tens of thousands of elements that stay smooth to pan, zoom and spin.

The demo below builds a scale-free graph (hubs emerge, like a real network), lays it out, and frames it. Switch the renderer (WebGPU, WebGL2 or Canvas 2D) and the node count, re-run the layout, or hit Spin to rotate the camera continuously and read the live frame rate — flip between renderers while spinning to compare them at the same scale.

1,000 → 50,000 nodes · WebGPU / WebGL2 / CanvasOpen in new tab ↗

Graphs this size are cheapest to build by hand — a plain { nodes, edges } object. Here each new node attaches to a couple of existing nodes chosen by preferential attachment (degree-weighted), so a few hubs accumulate most of the links. Generating 50,000 nodes this way takes only a few milliseconds.

function buildGraph(N, M = 2) {
const nodes = [];
const edges = [];
const endpoints = []; // node ids repeated by degree → degree-weighted picks
for (let i = 0; i < N; i++) {
nodes.push({ id: i });
if (i === 0) continue;
const picked = new Set();
for (let k = 0; k < Math.min(M, i); k++) {
let t;
do { t = endpoints.length ? endpoints[(Math.random() * endpoints.length) | 0] : 0; }
while (picked.has(t));
picked.add(t);
edges.push({ source: i, target: t });
endpoints.push(i, t); // bump both endpoints' degree weight
}
}
return { nodes, edges };
}
await nodus.setGraph(buildGraph(50000));

Prefer a named topology? nodus.generate.* ships barabasiAlbert, erdosRenyi, randomTree, grid and more — see Generating Graphs.

The force layout is a Barnes–Hut approximation (the theta option trades accuracy for speed). Two engines back it:

  • CPU — a Rust/WebAssembly simulation that runs off the main thread in a worker, so the page stays responsive while nodes settle.
  • GPU (gpu: true) — a WebGL2 simulation that steps the whole particle system on the GPU. It is ~20× faster at scale (50,000 nodes lay out in ~2 s instead of ~40 s) and falls back to the CPU engine automatically when WebGL2 is unavailable. The demo uses it for its larger tiers (≥25k), where it matters; smaller graphs stay on the robust off-main-thread CPU engine.

Pass duration: 0 to jump straight to the settled positions — best for large graphs — then frame the result.

await nodus.layouts.force({ duration: 0, gpu: true });
await nodus.view.locateGraph();

Layout is a one-time cost that grows with the graph; rendering and interaction stay fast regardless. If the user switches scale mid-run, nodus.layouts.stop() cancels the in-flight layout.

At 50,000 nodes everything overlaps, so scale the visuals down as the graph grows — smaller radii and thinner, fainter edges keep the structure readable instead of a solid blob. Function-valued rules let one rule set adapt to the current scale without being re-added:

let style = { radius: 2.2, edgeWidth: 0.5, edgeColor: '#64748b' };
nodus.styles.addNodeRule({ radius: () => style.radius, color: '#2563eb' });
nodus.styles.addEdgeRule({ width: () => style.edgeWidth, color: () => style.edgeColor });

Nodus picks a renderer for you, but you can choose one explicitly. The renderer option takes 'wasm' — the Rust/wgpu GPU renderer, which prefers WebGPU and falls back to WebGL2 — or 'canvas' for the Canvas 2D renderer:

const nodus = new Nodus({ container, renderer: 'wasm' }); // GPU (WebGPU → WebGL2)
// const nodus = new Nodus({ container, renderer: 'canvas' }); // Canvas 2D

The demo’s WebGPU / WebGL2 / Canvas 2D buttons let you feel the difference. Switching renderers re-creates the instance but reuses the laid-out positions, so it redraws the same picture instantly instead of paying for the layout again — read each node’s position, then hand them straight back as x/y attributes:

// snapshot the current layout
const laidOut = {
nodes: [...nodus.getNodes()].map((n) => {
const { x, y } = n.getPosition();
return { id: n.getId(), attributes: { x, y } };
}),
edges,
};
// ...re-create on another renderer, then redraw with no layout step:
await nextNodus.setGraph(laidOut);
await nextNodus.view.locateGraph();

Use isWebGpuSupported() to detect WebGPU up front — the demo disables the WebGPU button when it is unavailable. See Backends & Targets for what each backend ships.

The Spin button rotates the camera every frame and counts how many frames land per second — a direct read on rendering throughput at the current element count. Spin under each renderer to compare them: WebGPU is typically the fastest, with WebGL2 close behind and Canvas 2D trailing as the graph grows.

function spin() {
nodus.view.rotate(0.004); // a small camera rotation each frame
requestAnimationFrame(spin);
}

Layout and rendering scale differently, and it helps to treat them separately:

  • Rendering is the GPU’s job and scales furthest — the instanced renderer draws 100,000+ nodes and stays interactive to pan and zoom.
  • Force layout is the heavier step. The GPU engine (gpu: true) keeps it practical to ~50,000 nodes (the demo’s ceiling); past that, lay the graph out once with a cheaper layout (e.g. nodus.layouts.grid()) or precompute positions and pass them as x/y attributes, skipping force entirely.

In other words, if you already know where nodes go, Nodus will render far more of them than it will freshly force-lay.