Running graph-explorer in a browser
The site documents what the API does. This documents what the browser demands of you — the canvas contract, how the wasm binary gets served, what a real page has to do about resizing, DPI, idling and teardown, and the failures you get when one of those is wrong.
Everything here is specific to @benjamin-small/graph-explorer. None of it is
optional folklore: each rule below exists because breaking it produces a
particular, reproducible failure, and the failure is named.
1. What the browser must provide
| Requirement | Why | Floor |
|---|---|---|
| WebGL2 | The renderer's only backend. wgpu is the abstraction; the backend is pinned to GL. | Chrome/Edge 56, Firefox 51, Safari 15 |
| WebAssembly | The engine is Rust compiled to wasm. | Same era as WebGL2 — anything with WebGL2 has it |
| A DOM | mountGraph looks the canvas up by id and hands it to wgpu. |
— |
There is no software fallback and no 2D-canvas path. A browser without WebGL2 cannot run this library; feature-detect and show something else:
const ok = !!document.createElement("canvas").getContext("webgl2");
This is not a Node.js library. It has no server-side rendering path — the
module reaches for document and a GPU adapter during mount. See
§9 Frameworks and SSR for how to keep it out of a
server bundle.
2. Getting the wasm to the browser
The compiled binary ships inside the npm package (pkg/). There is nothing
to host separately and nothing to configure — but it has to survive your build.
Your bundler must emit .wasm as an asset
The URL is resolved with new URL("../pkg/…_bg.wasm", import.meta.url), so the
bundler fingerprints and copies it. Vite, Webpack 5, Rollup and esbuild all
handle this. A bundler that inlines or drops unknown asset types will produce a
module that builds cleanly and fails at init().
The repository's own containerized consumer check asserts the asset survives —
ls dist/assets/*.wasm after a vite build from a clean npm install. See
docker/README.md.
Vite dev: exclude it from dependency pre-bundling if the wasm 404s
Vite's dep optimizer rewrites modules into .vite/deps, which relocates them
relative to the .wasm and breaks new URL(…, import.meta.url). The URL then
points somewhere the file is not, the dev server answers the 404 with
index.html, and the loader reports a WebAssembly magic-word error against
<!do… — which is HTML, not a corrupt binary.
// vite.config.ts
export default defineConfig({
optimizeDeps: { exclude: ["@benjamin-small/graph-explorer"] },
});
This repository carries exactly that exclusion for a sibling wasm package (see vite.config.ts) after hitting the failure above. Reach for it when you see that symptom; it is not needed for production builds, where pre-bundling does not run.
Serve .wasm as application/wasm
The loader tries WebAssembly.instantiateStreaming first. Streaming requires
the correct Content-Type; with anything else the glue falls back to
arrayBuffer() + instantiate() and logs:
WebAssembly.instantiateStreamingfailed because your server does not serve Wasm withapplication/wasmMIME type. Falling back toWebAssembly.instantiatewhich is slower.
So a wrong MIME type works — it just buffers the whole ~3.7 MB before compiling instead of compiling as it arrives, and leaves a warning in every user's console. Most static hosts get this right; hand-rolled Express and some CDN rewrite rules do not.
Content-Security-Policy
If your page sets a CSP, script-src needs 'wasm-unsafe-eval' — compiling a
WebAssembly module is gated by that source expression, and without it the
browser refuses the module outright. It does not imply 'unsafe-eval';
it permits wasm compilation and nothing else.
Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval'
Nothing else about the library is unusual for CSP: no inline scripts, no
workers, no eval. No cross-origin isolation (COOP/COEP) is needed either —
the engine is single-threaded and uses no SharedArrayBuffer.
Caching
The bundler fingerprints the .wasm filename, so it is safe to serve with a
long-lived immutable cache. It is by far the largest thing your page fetches
(~3.7 MB raw, ~1.4 MB gzipped as of v0.6.0 — see CHANGELOG),
and it never changes for a given version. Enabling Brotli on it is worth more
than any other transfer optimization you can make here.
3. The canvas contract
Five rules. The first is fatal if broken; the rest are visual.
3.1 Size the canvas before mounting
GraphView.mount reads canvas.width / canvas.height at GPU-init time, and
rejects a zero-sized canvas:
canvas has zero size; size it before mount()
That check is deliberate. A zero-sized wgpu surface is a validation error, and on wasm a validation error is a panic that takes the whole instance with it — a dead engine for the rest of the session, not a degraded one. A clear rejection is the better failure.
clientWidth/clientHeight can read 0 if you measure before the browser's
first layout pass, and in a hidden or backgrounded tab both the element and
the viewport measure 0. Fall back to a nominal size so startup completes, and
let your resize handler correct it when the tab is actually shown.
3.2 The backing store is in physical pixels
canvas.width is the drawing buffer; CSS width is the displayed box. On a
HiDPI display they differ by devicePixelRatio. Size the buffer to
cssSize × ratio and let CSS keep the box at the CSS size — the browser
downsamples, which is the point: lines and glyphs rasterize at twice the detail
instead of being drawn at half resolution and stretched.
Cap the ratio at 2. Uncapped, a 3× phone display shades nine fragments per CSS pixel for a sharpness gain nobody can see.
3.3 Tell the engine the ratio separately
setPixelRatio(r) resizes nothing. It supplies the interpretation: the
engine states a handful of sizes in pixels itself — label type size, the
node-to-label gap, hit-test slop, the label LOD cutoffs — all chosen by eye at
1×. Without the ratio it renders them at half their intended apparent size on a
2× canvas: 7pt text, and labels at twice the density they should be.
Geometry needs no such adjustment and gets none. Node radii and link distances are world units, and a doubled viewport doubles the fitted zoom, so framed content keeps its proportions and only gains detail.
3.4 Re-size on layout changes and on DPI changes
They are different events and neither implies the other:
- A sidebar opening resizes the element with no window resize → needs a
ResizeObserver. - Dragging a window to a monitor with a different density changes the ratio
with no element resize → needs the
windowresize listener.
resize() does not re-fit the camera, so the user's pan and zoom survive it.
3.5 Very large canvases are clamped, not fatal
wgpu treats a surface larger than max_texture_dimension_2d as a validation
error — again, instance death. Every path that hands a size to the surface goes
through fit_within_max_dimension first, which shrinks both axes by the same
factor and writes the result back to the canvas element.
Both axes on purpose: clamping only the offending one would change the drawing buffer's aspect ratio while the CSS box kept its shape, and every circle would render as an ellipse.
Practically this only bites past ~4096px per axis on desktop GL. You lose sharpness on an unusually large canvas; you do not lose the engine. (Before v0.3.0 the cap was the WebGL2 downlevel default of 2048, so a 2560px window was enough to kill it — that is the bug the clamp exists to prevent recurring.)
Reference implementation
import { mountGraph } from "@benjamin-small/graph-explorer/mount";
const canvas = document.getElementById("graph") as HTMLCanvasElement;
// Read fresh every time — this is what makes moving the window between
// monitors work.
const pixelRatio = () => Math.min(window.devicePixelRatio || 1, 2);
function sizeCanvas(): boolean {
const ratio = pixelRatio();
// The fallbacks cover measuring before first layout, and a hidden tab where
// every measurement is 0.
const w = Math.round((canvas.clientWidth || window.innerWidth || 1280) * ratio);
const h = Math.round((canvas.clientHeight || window.innerHeight || 720) * ratio);
if (canvas.width === w && canvas.height === h) return false;
canvas.width = w;
canvas.height = h;
return true;
}
sizeCanvas(); // BEFORE mount — mount reads these
const client = await mountGraph("graph"); // by element id, not the element
client.setPixelRatio(pixelRatio());
const apply = () => {
if (sizeCanvas()) client.resize(canvas.width, canvas.height);
client.setPixelRatio(pixelRatio());
};
const ro = new ResizeObserver(apply); // layout changes
ro.observe(canvas);
window.addEventListener("resize", apply); // DPI changes
// teardown
ro.disconnect();
window.removeEventListener("resize", apply);
client.dispose();
The /react entry point does all of this for you, including the
ResizeObserver and the maxPixelRatio cap (default 2).
4. Coordinates are physical pixels
nodeAt, dragTo, and the node-list accessors all speak the canvas backing
store, not CSS pixels. One convention across the whole surface, so a host
applies the conversion once.
getBoundingClientRect() gives CSS pixels, so converting a mouse event means
scaling by the backing-store ratio:
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
const hit = client.nodeAt((e.clientX - rect.left) * sx, (e.clientY - rect.top) * sy);
attachPointer already does this, along with click-vs-drag, pointer capture and
wheel zoom. Prefer it:
import { attachPointer } from "@benjamin-small/graph-explorer/pointer";
const pointer = attachPointer(client, canvas, { // (client, canvas) — in that order
onNodeClick: (hit) => console.log(hit.id),
});
// later
pointer.detach();
detach() matters beyond removing listeners: detaching mid-drag would
otherwise leave the pointer captured by the canvas and a node pinned by a drag
that can no longer be ended.
5. Mount and teardown
const client = await mountGraph("graph"); // canvas element id
client.setStyle({ /* StyleSpec */ });
client.load(JSON.stringify({ nodes, edges }));
client.fitView();
client.start();
Concurrent mounts are safe. Two GraphView.mount() calls overlapping in
time panic the wasm instance outright, so both mountGraph and the engine's own
mount serialize GPU initialization internally and calls simply take their
turn. This is not a limit on how many engines exist — only the overlap of the
init itself is unsafe. It matters in practice because React's StrictMode
double-invokes effects, so a single component issues two overlapping mounts by
itself.
Queuing delays a mount; it does not cancel one. If you abandon a mount
before it resolves, you must still dispose() the client you eventually
receive, or you leak a WebGL context.
Always dispose(). It aborts in-flight fetches and frees the wasm object.
After disposal every method throws a clear error rather than reading freed wasm
memory:
GraphClient has been disposed; create a new client via mountGraph()
6. The render loop in a real page
start() runs a requestAnimationFrame loop that parks itself when nothing
is in motion, and any mutating call wakes it. An untouched explorer costs
nothing rather than rebuilding and re-uploading its scene every 16ms forever.
running— whether you asked for a loop. A parked loop is stillrunning.scheduled— whether a frame is pending right now.
Three things prevent a full idle:
- Halos pulse by default, and halos mark the current node. A pulsing halo
takes a reduced path — the previous frame's nodes, edges and labels are reused
and only the halo buffer is re-uploaded — but it still draws. Set
reduced_motionin theAnimationSpecto freeze the pulse and let the loop park completely. - An in-flight fetch always keeps the loop awake, because the fetch pump runs per frame. Parking with a request outstanding would strand its result.
- A configured idle animation (
AnimationSpec.idlewith a preset other than"none", or a registeredsetIdleFncallback) keeps the loop from parking by design — that is the trade it offers. See below.
Measured at 2000 nodes on a 2400×1600 backing store: a full frame ~1.3ms GPU-synced, a halo-only frame ~0.9ms, a parked graph nothing at all.
Idle animation
Off by default. AnimationSpec.idle enables ambient motion — drift
(position), breathe (radius), or shimmer (opacity) — that plays only after
dwell_ms (default 2000) of complete quiet, eases in over ease_in_ms, and is
suppressed the instant anything real happens: any interaction, simulation
activity, camera glide, or fetch stops it that frame and restarts the dwell.
reduced_motion disables it entirely.
Two cheaper frame tiers make it affordable:
- Dwelling (quiet, but the countdown hasn't elapsed): the loop stays scheduled but draws nothing — per frame, roughly the cost of a classification check. The loop cannot park here, because a parked engine can never observe the timer that would wake it.
- Ambient (motion playing): frame assembly re-runs — nodes, edges, labels and halos follow the motion, and hit-testing follows what you see — but styling, styler callbacks, the fetch pump and the simulation are all skipped. Cheaper than a full frame, more than halo-only.
drift has a mode: "visual" (default) displaces draw-time geometry only,
so graphNodes()/storeNodes() keep reporting rest positions and layout truth
never moves; "physical" nudges the real world positions (bounded around
captured rest anchors, restored before any mutation acts), so hosts diffing
node lists see the motion too. Physical requires the force layout to own
positions (Traditional view); elsewhere it falls back to visual.
setIdleFn((id, tMs, rest) => ({dx?, dy?, scale?, opacity?} | null)) computes
custom motion in JS, replacing the preset per node where it returns non-null.
Cost, stated plainly: it runs once per node per frame while ambient —
2000 nodes at 60fps is 120k wasm→JS crossings a second. The presets never cross
the boundary; prefer them beyond a few hundred nodes. The callback never runs
while suppressed or dwelling.
Driving your own loop
Skip start() and call render(now) yourself. It returns whether another frame
is wanted, so you can idle on the same signal:
function frame(now: number) {
if (client.render(now)) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
If you change something the engine cannot observe — a styler callback that
starts returning different answers without being re-registered is the realistic
case — force a repaint. This is on the raw engine, not on GraphClient:
client.raw.invalidate?.();
Hidden tabs
requestAnimationFrame does not fire in a hidden or backgrounded tab. A client
started while the tab is hidden renders nothing until the tab is shown — which
is correct behaviour, but worth knowing before you debug it. Combined with §3.1,
a tab that is hidden at mount time measures 0 everywhere; use the nominal
fallback and let the resize handler correct it.
7. Several explorers on one page
Supported, and they are independent: they render side by side, and disposing one does not disturb the others.
The ceiling is the browser's WebGL context limit, not this library. Chrome allows 16 live contexts per page and force-loses the oldest beyond that; a canvas whose context was lost goes blank. Measured on Chrome:
| Live explorers | Contexts lost | Errors |
|---|---|---|
| 8 | 0 | 0 |
| 16 | 0 | 0 |
| 24 | 8 | 0 |
Exactly N − 16. One explorer holds one context, so budget accordingly, and
dispose explorers you are no longer showing rather than leaving them mounted
off-screen. A discarded canvas does not release its context until it is garbage
collected, so churning through mounts can trip the cap even when few are live at
once.
8. Context loss
The library does not handle webglcontextlost. Nothing listens for it and
there is no automatic recovery: a lost context leaves a blank canvas and a
client whose calls no longer draw anything.
A context can be lost for reasons that have nothing to do with your page — a GPU driver reset, the OS reclaiming resources, another tab exhausting the context cap. If your page must survive that, own it:
canvas.addEventListener("webglcontextlost", (e) => {
e.preventDefault(); // otherwise restore can never happen
client.dispose();
// re-mount when you're ready; the canvas element can be reused
});
This is a genuine gap rather than a design decision, and it is called out here so it is not discovered in production.
9. Frameworks and SSR
React
Use the component — it owns mounting, sizing, DPI, pointer wiring and teardown:
import { GraphExplorer } from "@benjamin-small/graph-explorer/react";
<GraphExplorer
graph={graphJson}
styleSpec={styleSpec}
maxPixelRatio={2}
onReady={(client) => { /* GraphClient | null — null on unmount */ }}
/>
style and className on this component apply to the wrapper element, not
to the graph — the graph's appearance is styleSpec.
Driving a client manually instead: GraphClient implements the
useSyncExternalStore contract directly, and subscribe/getSnapshot are
bound so they can be passed detached.
const snap = useSyncExternalStore(client.subscribe, client.getSnapshot);
The snapshot is reference-stable — replaced only when currentId, mode,
loading or error actually changes.
Svelte
import { toStore } from "@benjamin-small/graph-explorer";
const graph = toStore(client); // $graph.currentId, $graph.mode, …
Neither framework is a dependency.
SSR — Next.js, Remix, SvelteKit, Astro
The /mount entry pulls in the wasm binary and touches document. It must
never be evaluated on the server. Import it dynamically, inside a
browser-only lifecycle:
// inside useEffect / onMount — never at module scope
const { mountGraph } = await import("@benjamin-small/graph-explorer/mount");
The root entry (@benjamin-small/graph-explorer) is deliberately wasm-free
and DOM-free — types, Emitter, toStore, initialSnapshot. It is safe to
import anywhere, including in server code and shared type modules. That split is
the reason the entry points exist.
For Next.js App Router, mark the containing component "use client", or reach
for next/dynamic with { ssr: false }.
10. What to expect on first load
The wasm is ~3.7 MB raw, ~1.4 MB gzipped, so first paint waits on that transfer. It is a one-time, cacheable cost, not a per-interaction one, but plan for it:
- Serve it with Brotli and an immutable cache header.
- Show something during
await mountGraph(...)— the promise does not resolve until the binary has downloaded, compiled and initialized the GPU. - Preload it if the graph is above the fold:
<link rel="preload" as="fetch" crossorigin href="/assets/….wasm">.
Most of that mass is dependencies rather than engine code — wgpu, naga's WGSL frontend and GLSL backend, and the glyph stack. The CHANGELOG carries the per-release breakdown.
11. When something goes wrong
| Symptom | Cause | Fix |
|---|---|---|
canvas has zero size; size it before mount() |
Mounted before layout, or in a hidden tab | Set canvas.width/height first; use nominal fallbacks (§3.1) |
WebAssembly magic word error mentioning <!do… |
The .wasm request 404'd and the dev server answered with index.html |
optimizeDeps.exclude in Vite dev (§2) |
Console warning about instantiateStreaming and MIME type |
Server does not send application/wasm |
Fix the MIME type; it works either way, just slower |
| CSP blocks the module | Missing 'wasm-unsafe-eval' |
Add it to script-src (§2) |
| Circles render as ellipses | Backing store aspect ratio ≠ CSS box aspect ratio | Size the buffer from the element's own CSS size × ratio (§3.2) |
| Everything is blurry on a retina display | Backing store sized in CSS pixels | Multiply by devicePixelRatio (§3.2) |
| Labels are tiny or absurdly dense | Buffer scaled but setPixelRatio never called |
Call it after every ratio change (§3.3) |
| Clicks land on the wrong node | Passing CSS pixels to nodeAt/dragTo |
Scale by the backing-store ratio, or use attachPointer (§4) |
| A canvas goes blank with others on the page | WebGL context cap; the oldest was force-lost | Dispose off-screen explorers (§7) |
| Canvas goes blank after a GPU hiccup | Context loss, unhandled by the library | Listen for webglcontextlost and re-mount (§8) |
Nothing renders, no errors, running === true |
Loop is parked (correct) or the tab is hidden | Check scheduled; see §6 |
| Stale picture after changing a styler callback | Engine cannot observe the change | client.raw.invalidate?.() (§6) |
GraphClient has been disposed… |
Use after dispose() |
Create a new client via mountGraph() |
| Camera lurches unexpectedly after a drag | fit_on_settle in the AnimationSpec |
It re-frames only after a drag settles; unset it if unwanted |
See also
| Site | API reference and live demos |
| Package README | Install, entry points, framework integration |
| CHANGELOG | Behaviour changes between versions |
| docker/README.md | How packaged-consumer verification works |