Skip to main content

Large-Scale Node-Link Visualization

Interview framing

Design an interactive node-link visualization that supports:

  • Up to 100K nodes
  • Hundreds of thousands or millions of edges
  • Pan, zoom, search, filter, hover, selection, and drill-down
  • An initial snapshot followed by live deltas
  • Correct event ordering, replay, reconnect, and resynchronization
  • Version comparison and historical graph inspection
  • Clear ownership across platform and product teams

The complete graph is the canonical data model.
The visible graph is a level-of-detail projection optimized for the current viewport and user intent.


1. Requirements

Functional requirements

  1. Load an initial graph snapshot.
  2. Render nodes and edges.
  3. Support smooth pan and zoom.
  4. Search by node ID, label, type, or metadata.
  5. Filter by node type, owner, status, or time range.
  6. Select a node and inspect its details.
  7. Expand a node to retrieve neighbors.
  8. Receive live graph changes.
  9. Compare graph revisions.
  10. Recover after network interruption without silently losing updates.

Non-functional requirements

RequirementTarget
First meaningful graphUnder 2 seconds
Interaction responseUnder 100 ms
Search responseUnder 300 ms
Pan and zoom60 FPS where practical
Live update latencyUnder 1–2 seconds
MemoryBounded and observable
RecoveryNo silent loss or reordering
AccessibilityKeyboard and nonvisual navigation

2. Architecture overview

Graph Snapshot API ──┐
├──> Graph Sync Engine ──> Canonical Graph Store
Delta Stream ────────┘ │
├──> UI selectors
├──> Search/filter worker
└──> Render adapter ──> WebGL

The frontend is separated into three systems:

  1. Canonical graph state
  2. Product and interaction state
  3. Imperative rendering state

These systems update at different frequencies and should not share one React render path.


3. Rendering technology

Recommendation

Use WebGL2 as the initial rendering backend, behind a renderer interface that allows a later WebGPU implementation.

TechnologyGood fitMain limitation
SVGSmall graphs and rich DOM semanticsDOM and layout cost
Canvas 2DMedium graphsCPU-bound rendering
WebGL2100K+ nodesMore rendering complexity
WebGPUCompute-heavy future workloadsCompatibility and maturity

React ownership

React owns:

  • Toolbar
  • Search
  • Filters
  • Details panel
  • Timeline controls
  • Loading and error surfaces
  • Accessible list/table views

The renderer owns:

  • Node geometry
  • Edge geometry
  • GPU buffers
  • Camera transforms
  • Hit testing
  • Level of detail
  • Animation

Do not render one React component per node.

function GraphPage() {
return (
<GraphProvider>
<GraphToolbar />
<GraphViewport />
<NodeDetailsPanel />
<AccessibleGraphList />
</GraphProvider>
);
}
function GraphViewport() {
const canvasRef = useRef<HTMLCanvasElement>(null);

useEffect(() => {
if (!canvasRef.current) return;

const renderer = createGraphRenderer(canvasRef.current);

return () => renderer.destroy();
}, []);

return <canvas ref={canvasRef} />;
}

4. GPU rendering model

Use a small number of batched draw calls.

interface NodeInstance {
x: number;
y: number;
size: number;
colorIndex: number;
iconIndex: number;
flags: number;
}

interface EdgeInstance {
sourceIndex: number;
targetIndex: number;
colorIndex: number;
flags: number;
}

Recommended techniques:

  • Instanced node rendering
  • Typed arrays
  • Texture atlas for icons
  • Batched edge rendering
  • Dirty-range buffer updates
  • Offscreen picking framebuffer
  • Render mutation coalescing once per animation frame
function queueRenderChanges(changes: RenderMutationBatch) {
mergeMutationBatch(pendingChanges, changes);

if (flushScheduled) return;
flushScheduled = true;

requestAnimationFrame(() => {
renderer.applyMutations(pendingChanges);
pendingChanges = createEmptyMutationBatch();
flushScheduled = false;
});
}

5. Level of detail

Rendering 100K nodes may be possible. Rendering every edge and label simultaneously is usually neither performant nor useful.

Far zoom

  • Render clusters
  • Hide labels
  • Hide low-priority edges
  • Use density or heatmap rendering
  • Show aggregate counts

Medium zoom

  • Render individual nodes
  • Render important edge types
  • Show labels for important nodes

Near zoom

  • Render detailed node icons
  • Show labels
  • Show local topology
  • Enable richer hover and selection states
type RenderMode = 'clusters' | 'nodes' | 'detailed';

function getRenderMode(zoom: number): RenderMode {
if (zoom < 0.2) return 'clusters';
if (zoom < 0.8) return 'nodes';
return 'detailed';
}

Edge reduction

  • Draw only edges whose endpoints are visible.
  • Prioritize selected and hovered neighborhoods.
  • Aggregate parallel edges.
  • Filter by importance.
  • Bundle static or slowly changing edges.
  • Query neighborhoods on demand.
  • Cap visible edges with a clear UI indicator.

6. Core data model

Node

type NodeId = string;
type GraphVersion = number;
type SequenceNumber = number;

interface GraphNode {
id: NodeId;
kind: string;
label?: string;
status?: 'healthy' | 'warning' | 'error' | 'unknown';
attributes: Record<string, unknown>;

position?: {
x: number;
y: number;
};

createdAt: string;
updatedAt: string;
entityVersion: number;
}

Edge

type EdgeId = string;

interface GraphEdge {
id: EdgeId;
sourceId: NodeId;
targetId: NodeId;
kind: string;
directed: boolean;
attributes: Record<string, unknown>;
createdAt: string;
updatedAt: string;
entityVersion: number;
}

Snapshot

interface GraphSnapshot {
graphId: string;
snapshotId: string;
graphVersion: GraphVersion;

// Last event included in this snapshot.
lastSequence: SequenceNumber;

nodes: GraphNode[];
edges: GraphEdge[];

generatedAt: string;
schemaVersion: number;
}

Snapshot chunk

interface SnapshotChunk {
snapshotId: string;
chunkIndex: number;
chunkCount: number;
nodes: GraphNode[];
edges: GraphEdge[];
checksum?: string;
}

7. Delta contract

Use explicit event operations instead of ambiguous partial objects.

interface DeltaBase {
eventId: string;
graphId: string;
sequence: number;

// State expected before and after applying this event.
baseGraphVersion: number;
graphVersion: number;

schemaVersion: number;
occurredAt: string;
}
type GraphDelta =
| NodeUpsertDelta
| NodeDeleteDelta
| EdgeUpsertDelta
| EdgeDeleteDelta
| BatchDelta;

interface NodeUpsertDelta extends DeltaBase {
type: 'node.upsert';
node: GraphNode;
}

interface NodeDeleteDelta extends DeltaBase {
type: 'node.delete';
nodeId: NodeId;
entityVersion: number;
}

interface EdgeUpsertDelta extends DeltaBase {
type: 'edge.upsert';
edge: GraphEdge;
}

interface EdgeDeleteDelta extends DeltaBase {
type: 'edge.delete';
edgeId: EdgeId;
entityVersion: number;
}

interface BatchDelta extends DeltaBase {
type: 'batch';
operations: Array<
| Omit<NodeUpsertDelta, keyof DeltaBase>
| Omit<NodeDeleteDelta, keyof DeltaBase>
| Omit<EdgeUpsertDelta, keyof DeltaBase>
| Omit<EdgeDeleteDelta, keyof DeltaBase>
>;
}

Contract guarantees

The backend contract should provide:

  • At-least-once delivery
  • Stable event IDs
  • Monotonic sequence numbers
  • Base and resulting graph versions
  • Entity versions
  • Schema versions
  • Atomic mutation batches
  • Replay by sequence
  • Defined retention window

8. Snapshot plus delta synchronization

  1. Client requests a snapshot.
  2. Snapshot contains lastSequence.
  3. Client loads and validates all snapshot chunks.
  4. Client atomically installs the snapshot.
  5. Client subscribes using afterSequence.
  6. Server replays events after the snapshot watermark.
  7. Client processes only contiguous events.
Snapshot contains events through sequence 10,000
Client subscribes with afterSequence=10,000
First valid delta is sequence 10,001

This prevents losing changes that occur while the snapshot is being transferred.

Alternative

When replayable snapshot watermarks are unavailable:

  1. Subscribe first.
  2. Buffer incoming deltas.
  3. Load the snapshot.
  4. Discard buffered events already covered by the snapshot.
  5. Apply the remaining buffered events in order.

This reduces race risk but increases frontend complexity.


9. Ordering and deduplication

interface SyncState {
status: 'idle' | 'loading-snapshot' | 'streaming' | 'recovering' | 'resyncing' | 'failed';

snapshotId?: string;
graphVersion: number;
lastAppliedSequence: number;

pendingBySequence: Map<number, GraphDelta>;
recentEventIds: Set<string>;
}
function receiveDelta(state: SyncState, delta: GraphDelta) {
if (state.recentEventIds.has(delta.eventId)) {
return;
}

if (delta.sequence <= state.lastAppliedSequence) {
return;
}

const expected = state.lastAppliedSequence + 1;

if (delta.sequence > expected) {
state.pendingBySequence.set(delta.sequence, delta);
requestReplay(expected, delta.sequence - 1);
return;
}

applyAndAdvance(state, delta);
flushContiguousPending(state);
}
function applyAndAdvance(state: SyncState, delta: GraphDelta) {
if (delta.baseGraphVersion !== state.graphVersion) {
triggerFullResync('graph-version-mismatch');
return;
}

graphStore.applyAtomically(delta);

state.graphVersion = delta.graphVersion;
state.lastAppliedSequence = delta.sequence;
rememberEventId(delta.eventId);
}
function flushContiguousPending(state: SyncState) {
while (true) {
const nextSequence = state.lastAppliedSequence + 1;
const nextDelta = state.pendingBySequence.get(nextSequence);

if (!nextDelta) return;

state.pendingBySequence.delete(nextSequence);
applyAndAdvance(state, nextDelta);
}
}

Gap timeout

If replay cannot repair the gap within a bounded time:

  1. Pause live mutation application.
  2. Keep the last valid graph visible.
  3. Mark the view as stale.
  4. Fetch a new snapshot.
  5. Replace canonical state atomically.
  6. Resume from the new watermark.

10. Reconnect state machine

DISCONNECTED


CONNECTING

├── success ──> STREAMING

└── failure ──> BACKOFF

└──> CONNECTING

STREAMING
│ disconnect

RECONNECTING

├── replay available ──> CATCHING_UP ──> STREAMING

└── cursor expired ────> RESYNCING ────> STREAMING

Resume request

{
"type": "subscribe",
"graphId": "graph-123",
"afterSequence": 42018,
"snapshotId": "snapshot-abc",
"clientSchemaVersion": 3
}

Resume response

type SubscribeResponse =
| {
type: 'resume.accepted';
nextSequence: number;
}
| {
type: 'resume.rejected';
reason: 'cursor-expired' | 'snapshot-invalid' | 'schema-incompatible';
latestSnapshotId: string;
};

Backoff

function reconnectDelay(attempt: number) {
const maxDelayMs = 30_000;
const exponentialDelay = Math.min(maxDelayMs, 500 * 2 ** attempt);

return Math.random() * exponentialDelay;
}

Reset the reconnect attempt counter only after the connection has remained stable for a meaningful interval.


11. Normalized graph store

interface GraphStore {
nodesById: Map<NodeId, GraphNode>;
edgesById: Map<EdgeId, GraphEdge>;

outgoingEdgeIdsByNode: Map<NodeId, Set<EdgeId>>;
incomingEdgeIdsByNode: Map<NodeId, Set<EdgeId>>;

graphVersion: number;
lastAppliedSequence: number;
}
function upsertEdge(store: GraphStore, edge: GraphEdge) {
const previous = store.edgesById.get(edge.id);

if (previous && previous.entityVersion >= edge.entityVersion) {
return;
}

if (previous) {
removeEdgeFromIndexes(store, previous);
}

store.edgesById.set(edge.id, edge);

addToSet(store.outgoingEdgeIdsByNode, edge.sourceId, edge.id);

addToSet(store.incomingEdgeIdsByNode, edge.targetId, edge.id);
}

Referential integrity

An edge may reference a node that has not arrived yet.

Preferred policy:

  • Backend sends related mutations in an atomic batch.
  • Client maintains a bounded unresolved-edge buffer.
  • Buffer expiration triggers repair or resync.
  • The UI never silently drops a version-advancing mutation.

12. UI state versus graph state

interface GraphUiState {
selectedNodeIds: Set<NodeId>;
hoveredNodeId?: NodeId;

camera: {
x: number;
y: number;
zoom: number;
};

filters: GraphFilter[];
searchQuery: string;

activeRevision?: string;
comparisonRevision?: string;
}

Do not place camera and pointer movement in the canonical graph store.

StateTypical frequency
Pointer and hoverEvery frame
CameraEvery frame during navigation
Graph deltasBursty
FiltersOccasional
Snapshot replacementRare

13. Progressive loading

Loading stages

  1. Load graph metadata and saved viewport.
  2. Load clusters or high-priority nodes.
  3. Render an initial useful view.
  4. Stream node chunks.
  5. Stream visible-region edges.
  6. Build indexes in a worker.
  7. Enable expensive filters after indexing.
0–500 ms Viewport shell and metadata
500–1500 ms Clusters and important nodes
1.5–3 seconds Visible-region edges
Later Remaining graph and search index

Viewport-driven APIs

GET /graphs/:graphId/tiles?zoom=4&x=12&y=8
GET /graphs/:graphId/neighborhood?nodeId=N123&depth=2
GET /graphs/:graphId/search?q=payments

At larger scale, the browser should explore a server-backed graph instead of downloading every entity at startup.


14. Layout architecture

Do not execute a 100K-node force layout on the main thread.

Server layout

Use when:

  • Stable positions matter
  • The layout is shared
  • The full graph is expensive
  • Users need consistent topology

Worker layout

Use when:

  • The active subgraph is smaller
  • Users manipulate layout settings
  • Local refinement improves understanding

Hybrid recommendation

  1. Server provides stable global positions.
  2. Worker refines the visible subgraph.
  3. Pinned nodes retain coordinates.
  4. New nodes begin near connected neighbors.
Main thread:
- Input
- Camera
- WebGL rendering
- React controls

Worker:
- Layout
- Clustering
- Filtering
- Search indexing

Use transferable typed arrays instead of repeatedly cloning large object graphs.


15. Hit testing

Spatial index

Best for node hover:

  1. Convert pointer coordinates into graph coordinates.
  2. Query a quadtree or R-tree.
  3. Select the nearest visible node.

GPU picking

Best for complex shapes and edges:

  1. Render IDs into an offscreen framebuffer.
  2. Encode the object index as a color.
  3. Read the pointer pixel.
  4. Map the value back to a node or edge.

Throttle hover picking to animation frames because GPU readback can stall the pipeline.


16. Historical versions and graph diffs

Transport versioning and product-visible history are different concerns.

interface GraphRevision {
revisionId: string;
graphId: string;
parentRevisionId?: string;
createdAt: string;
createdBy: string;
reason?: string;
snapshotReference: string;
}

Prefer a backend diff endpoint:

GET /graphs/:graphId/diff?from=v100&to=v125
interface GraphVersionDiff {
fromVersion: number;
toVersion: number;

addedNodeIds: NodeId[];
removedNodeIds: NodeId[];
changedNodeIds: NodeId[];

addedEdgeIds: EdgeId[];
removedEdgeIds: EdgeId[];
changedEdgeIds: EdgeId[];
}

Visual treatment:

  • Added: emphasized
  • Removed: ghosted
  • Changed: outlined
  • Unchanged: muted

17. Accessibility

WebGL has no built-in semantic structure.

Provide:

  • Search-first graph navigation
  • Keyboard traversal through neighbors
  • Accessible node details panel
  • List or table view for visible results
  • Screen-reader announcements for selection
  • Reduced-motion support
  • Status indicators that do not depend only on color

Do not expose 100K accessibility nodes. Expose the current result set, selection, and navigable neighborhood.


18. Failure handling

Snapshot failure

  • Retry chunks independently.
  • Validate a consistent snapshotId.
  • Validate chunk count and checksums.
  • Install the snapshot atomically.
  • Distinguish partial display from synchronized state.

Stream failure

  • Preserve the last valid graph.
  • Mark live state as stale.
  • Resume from lastAppliedSequence.
  • Replay missing events.
  • Resnapshot when replay is unavailable.

Invalid delta

  • Record event ID and schema version.
  • Do not silently skip graph-version advancement.
  • Trigger replay or resync.
  • Log expected and actual sequence/version.

WebGL context loss

The canonical graph store remains intact. Reconstruct GPU buffers without re-fetching the graph.


19. Observability

User experience

  • Time to first meaningful graph
  • Time to complete snapshot
  • Search latency
  • Selection latency
  • FPS percentiles
  • Long tasks
  • Interaction delay
  • Memory usage

Synchronization

  • Delta lag
  • Gap count
  • Replay count
  • Reconnect count
  • Full resync count
  • Buffered event count
  • Snapshot mismatch count

Rendering

  • Visible node count
  • Visible edge count
  • Draw calls
  • Buffer size
  • Layout duration
  • Picking latency
  • Dropped frames

20. Ownership model

Visualization platform team

Owns:

  • Renderer
  • Camera and interactions
  • Level of detail
  • Hit testing
  • GPU resource management
  • Layout worker integration
  • Performance budgets
  • Rendering diagnostics

Graph platform team

Owns:

  • Snapshot generation
  • Delta event log
  • Sequence semantics
  • Replay retention
  • Graph versions
  • Historical revisions
  • Referential integrity
  • Schema compatibility

Product team

Owns:

  • Domain node and edge semantics
  • Search experience
  • Filters
  • Details panels
  • Visual styles
  • Product workflows
  • Permissions

Shared contracts

Joint ownership:

  • Node and edge schema
  • Snapshot and delta SLOs
  • Maximum payload size
  • Maximum burst size
  • Compatibility policy
  • Degradation behavior
  • Rollout and feature flags
  • Representative performance datasets

21. Staff-level execution plan

Phase 1: vertical slice

  • 10K nodes
  • Static snapshot
  • WebGL node rendering
  • Pan, zoom, selection
  • Server-provided positions
  • Performance telemetry

Phase 2: scale

  • 100K nodes
  • Edge batching
  • Level of detail
  • Spatial indexing
  • Worker filtering
  • Progressive snapshot chunks

Phase 3: live synchronization

  • Delta stream
  • Ordering and deduplication
  • Replay
  • Reconnect
  • Full resync
  • Schema versioning

Phase 4: product maturity

  • Revision comparison
  • Accessible alternate view
  • Saved views
  • Permissions
  • Operational dashboards

22. Key interview trade-offs

Why WebGL instead of SVG?

SVG provides excellent semantics but creates DOM, layout, and memory pressure at this scale. WebGL allows nodes and edges to be rendered in large GPU batches.

Why separate canonical and visible graphs?

The complete graph must remain correct, while the rendered projection must remain understandable and fast. Level of detail, filtering, and clustering derive the visible graph from canonical state.

Why server layout plus worker refinement?

The server provides stable global positions. The worker improves a smaller active subgraph without blocking input or rendering.

Why snapshot plus deltas?

Snapshots provide a complete recovery point. Deltas reduce bandwidth and preserve visual continuity. Sequence, graph versions, replay, and resnapshot make the combined model correct.

Why not update React for every event?

React owns product state, not per-frame graph geometry. Deltas update a normalized store and are coalesced into imperative renderer mutations.


23. Staff-level summary

I would separate the system into a normalized graph store, a snapshot-and-delta synchronization engine, and an imperative WebGL renderer. React owns product controls and accessible surfaces, but it does not render individual graph entities.

The snapshot contains a sequence watermark. The client subscribes after that watermark, applies only contiguous and version-compatible events, ignores duplicates, repairs gaps through replay, and replaces state from a fresh snapshot when the cursor expires.

At 100K nodes, the renderer uses instancing, typed arrays, progressive loading, workers, spatial indexing, and zoom-dependent level of detail. The full graph can be correct without drawing every edge or label at the same time.

The visualization platform owns rendering, the graph platform owns synchronization correctness, and product teams own domain semantics. The staff architect defines the contracts, budgets, degradation behavior, ownership boundaries, and rollout plan.