Skip to main content
Back to Writing

Optimizing Real-Time Kubernetes Visualizations

Published revised 8 min read

Labperformancethree.jscanvasd3kuberneteswebgloptimization
Optimizing Real-Time Kubernetes Visualizations — hero illustration

Real-time Kubernetes dashboards sound straightforward until you point them at changing cluster data. FlexDeck has both a force-directed Canvas 2D topology and a Three.js “HoloDeck” scene. The early implementations looked fine with toy inputs, but their design did too much work as object counts and update frequency grew.

I reviewed the FlexDeck implementation as of August 30, 2026: indexed lookups, spatial hit testing, pooled particles, batched WebGL geometry, worker-driven force simulation, and adaptive quality. The examples below explain those decisions; they are abbreviated rather than standalone programs.

TL;DR

  • Stop doing linear lookups in hot paths (Map/indices, spatial hashing).
  • Batch GPU work (merge line segments, use InstancedMesh).
  • Avoid per-frame allocations (object pools, precomputed strings).
  • Cache + invalidate intentionally; reconcile instead of rebuild.
  • Move layout work off the main thread when measurements justify the coordination cost.
  • Separate frame timing from layout-build timing. They answer different questions.

Terminology (quick disambiguation)

  • k8s node: a Kubernetes worker node (machine/VM) in the cluster.
  • graph node: a vertex in the visualization (pods/services/k8s nodes).

About the measurements

I do not have complete browser captures for the early implementation. The before/after code explains the changes, but it does not establish a reproducible frame-rate improvement.

The repository provides two ways to measure performance:

  • npm run perf:topology benchmarks deterministic topology-build scenarios and enforces a large-scenario budget. It measures layout/build work, not animation frames.
  • ?topologyPerf=1 and ?holodeckPerf=1 enable runtime performance HUDs with frame and build counters in the actual visualizations.

That distinction matters. For example, a 175 ms topology build and a hypothetical 12 ms animation frame are not competing measurements; they describe different phases of the system.

Why do all this?

I use these views to inspect workloads. If layout freezes the page or hover falls behind the pointer, the tool becomes harder to use during an incident.

On top of that, rendering work has a way of stealing time from everything else on the page. When frame times drift above budget, you don’t just lose smooth animation, you lose input responsiveness, battery, and trust.

How did it get this bad?

Honestly: I’d never done this before. FlexDeck’s visualization work was my first real attempt at SolidJS, Three.js, and real-time graph rendering. I started with a “cool visual” goal and wrote prototype-grade code to get pixels on the screen.

Early on, the easiest patterns were the worst ones for a render loop:

  • rebuild scenes instead of reconciling them
  • allocate objects/strings every frame and let GC deal with it
  • do linear scans (indexOf, “check every graph node”) in hot paths
  • create lots of small WebGL objects instead of batching geometry

Small fixtures hid the cost. Real cluster snapshots made the stutter obvious, so I used the Performance panel to identify the work that was holding up rendering.

The Starting Point

FlexDeck has two visualization surfaces that matter for performance:

TopologyGraph: A 2D force-directed graph using D3-force for layout and Canvas 2D for rendering. It shows k8s nodes, pods, and services with their relationships, plus animated “traffic” particles flowing along edges.

HoloDeck: A 3D visualization using Three.js: k8s nodes as towers, pods orbiting, service connections, and animated traffic particles. It’s a “command center” view of the same data, optimized for interactivity (pan/zoom/hover) under constant motion.

The initial implementations worked for small fixtures. Larger and more active snapshots exposed predictable problems:

  • UI freezing during initial graph layout
  • Dropped frames during zoom/pan interactions
  • Sluggish hover response times
  • Resource-lifecycle bugs around shared Three.js geometry and material disposal

The first clue was in the browser's DevTools. The Performance panel showed frame times well above 16ms, with suspicious spikes during certain operations. Time to dig in.

Phase 1: Algorithmic Complexity

Repeated lookups were the first hot path I changed.

TopologyGraph: Accidental O(N×M) Lookups

Every few frames, TopologyGraph spawns “traffic particles” that flow along connections between graph nodes. The original code used indexOf() to find graph node indices:

// BEFORE: O(N) lookup for each particle spawn
const sourceIdx = graphNodes.indexOf(source);
const targetIdx = graphNodes.indexOf(target);

With hundreds of graph nodes and frequent spawns, that meant thousands of linear scans per second. The fix was to precompute an index once per graph rebuild:

// AFTER: O(1) lookup with pre-built index
const nodeIndexById = new Map<string, number>();

// Built once when graph changes
graphNodes.forEach((node, idx) => nodeIndexById.set(node.id, idx));

// Used during particle spawning
const sourceIdx = nodeIndexById.get(source.id) ?? 0;
const targetIdx = nodeIndexById.get(target.id) ?? 0;

The current implementation keeps both nodeIndexMap and ID/UID maps close to the render state. The useful result is a bounded lookup shape; I do not have a reproducible per-frame delta for this change.

HoloDeck: 150+ Draw Calls for Pod Connections

Each pod in HoloDeck had a curved line connecting it to its parent k8s node. With ~150 pods, that’s 150+ THREE.Line objects, each with its own geometry and draw call:

// BEFORE: Individual geometry per pod connection
const curve = new THREE.QuadraticBezierCurve3(start, mid, end);
const points = curve.getPoints(24);
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, material);
scene.add(line);

The fix batches connections of the same material into a single BufferGeometry and uses THREE.LineSegments (pairs of vertices) so the renderer can draw everything in one call:

// AFTER: Batched geometry (expand curves into line segments)
const positions: number[] = [];

for (const pod of pods) {
  const curve = new THREE.QuadraticBezierCurve3(start, mid, end);
  const pts = curve.getPoints(16);

  for (let i = 0; i < pts.length - 1; i++) {
    const a = pts[i];
    const b = pts[i + 1];
    positions.push(a.x, a.y, a.z, b.x, b.y, b.z);
  }
}

const mergedGeom = new THREE.BufferGeometry();
mergedGeom.setAttribute(
  'position',
  new THREE.Float32BufferAttribute(positions, 3)
);

// Single draw call for all connections of the same material
const mergedLines = new THREE.LineSegments(mergedGeom, material);
scene.add(mergedLines);

If you need joins/width beyond basic lines, consider Three.js’s “fat line” utilities (Line2 in the examples package). The general idea still holds: batch by material and reduce object count.

The current scene builder batches pod lines by material/color and emits THREE.LineSegments. That reduces the number of scene objects and draw calls, but the exact reduction depends on the current snapshot and status-color distribution.

Phase 2: Memory Management

JavaScript's garbage collector is helpful until you're allocating thousands of objects per second in an animation loop.

Object Pooling for Particles

The traffic particle animation was creating and destroying objects constantly:

// BEFORE: New allocation every spawn
const particle = {
  x: startX,
  y: startY,
  progress: 0,
  speed: Math.random() * 0.02 + 0.01,
};
particles.push(particle);

// Later...
particles.splice(index, 1); // GC pressure

Object pooling pre-allocates a fixed set of slots and reuses them:

// AFTER: Zero-allocation object pool
interface ParticleSlot {
  active: boolean;
  sourceIdx: number;
  targetIdx: number;
  progress: number;
  speed: number;
}

const MAX_PARTICLES = 40;
const particlePool: ParticleSlot[] = Array.from(
  { length: MAX_PARTICLES },
  () => ({ active: false, sourceIdx: 0, targetIdx: 0, progress: 0, speed: 0 })
);

// Spawn: find inactive slot, set active = true
// Complete: set active = false (no splice, no GC)

The same pattern applies to HoloDeck's traffic particles, but using Three.js InstancedMesh for GPU-efficient rendering of many identical objects.

Shared Geometries and Materials in Three.js

Every pod in HoloDeck uses the same base shape and a small material set. Creating those resources per pod would be wasteful:

// BEFORE: New geometry per pod
const mesh = new THREE.Mesh(
  new THREE.DodecahedronGeometry(0.4),
  new THREE.MeshStandardMaterial({ color: podColor })
);

The fix caches shared resources:

// AFTER: Shared geometry, cached materials
const sharedGeoms: Record<string, THREE.BufferGeometry> = {
  pod: markShared(new THREE.DodecahedronGeometry(0.4)),
  // ... other shared geometries
};

const podMatCache = new Map<number, THREE.MeshStandardMaterial>();
const getPodMat = (color: number) => {
  if (!podMatCache.has(color)) {
    podMatCache.set(color, new THREE.MeshStandardMaterial({ color }));
  }
  return podMatCache.get(color)!;
};

// Usage
const mesh = new THREE.Mesh(sharedGeoms.pod, getPodMat(podColor));

Disposing shared resources too early

Shared resources introduced a subtle bug. My cleanup routine disposed resources that were still shared:

// OOPS: Disposed shared geometries too!
scene.traverse((obj) => {
  const mesh = obj as THREE.Mesh;
  if (mesh.geometry) mesh.geometry.dispose();
  const mat = (mesh as any).material;
  if (Array.isArray(mat)) mat.forEach((m: THREE.Material) => m.dispose());
  else if (mat) (mat as THREE.Material).dispose();
});

Blind traversal cleanup can dispose shared resources while another scene object still expects them. The fix marks shared resources and gives them an explicit lifecycle:

// Mark resources as shared
const markShared = (resource: THREE.BufferGeometry | THREE.Material) => {
  resource.userData.isShared = true;
  return resource;
};

// Only dispose non-shared resources on normal cleanup
const disposeObject = (obj: THREE.Object3D) => {
  const mesh = obj as THREE.Mesh;
  if (mesh.geometry && !mesh.geometry.userData?.isShared) {
    mesh.geometry.dispose();
  }
  // ... (handle materials too; remember multi-material arrays)
};

// Dispose shared resources only on final component unmount

Phase 3: Caching Strategies

I cached values that stayed constant between data updates and made the invalidation points explicit.

Node Style Cache with Invalidation

TopologyGraph's draw loop called getNodeRadius() and getNodeColor() for every graph node, every frame:

// BEFORE: Computed 200+ times per frame
graphNodes.forEach((node) => {
  const r = getNodeRadius(node); // Called every frame
  const color = getNodeColor(node); // Called every frame
  // ... draw operations
});

The fix builds a cache that's invalidated only when the graph node set changes:

// AFTER: Computed once, cached until data changes
let nodeStylesCache = new Map<
  string,
  { r: number; color: string; truncLabel: string }
>();
let nodeStylesCacheValid = false;

// In buildGraph(): nodeStylesCacheValid = false;

// In draw():
if (!nodeStylesCacheValid) {
  nodeStylesCache.clear();
  for (const node of graphNodes) {
    nodeStylesCache.set(node.id, {
      r: getNodeRadius(node),
      color: getNodeColor(node),
      truncLabel:
        node.label.length > 14 ? node.label.slice(0, 12) + '...' : node.label,
    });
  }
  nodeStylesCacheValid = true;
}

// Usage
const cached = nodeStylesCache.get(node.id)!;

Note the truncLabel optimization: label truncation was happening every frame, allocating new strings. Pre-computing truncated labels eliminates per-frame string allocation.

Reconcile instead of rebuild

Both components originally rebuilt their entire scene when props changed:

// BEFORE: Full rebuild on any change
createEffect(() => {
  clearScene();
  buildScene(props.nodes, props.pods, props.services);
});

The important shift was a reconcile model: keep scene objects keyed by stable IDs, compute the current ID sets, then add, update, or remove only what changed.

When you need a guard to skip work, make it correct rather than sampling the first and last object. FlexDeck tracks topology and style versions separately so a style-only change does not imply a full topology rebuild. A data layer with a monotonic revision can use the same pattern:

// AFTER: Skip work when the snapshot revision hasn’t changed
let lastRevision = '';

createEffect(() => {
  const revision = props.snapshot.revision; // e.g., watch event counter
  if (revision === lastRevision) return;
  lastRevision = revision;

  reconcileScene(props.snapshot); // add/update/remove, not clear+rebuild
});

Spatial grid indexing for bounded hover work

Hover detection was iterating through all 200+ graph nodes to find what's under the cursor:

// BEFORE: O(N) iteration on every mouse move
for (const node of graphNodes) {
  if (distanceTo(node, mousePos) < node.radius) {
    return node;
  }
}

A spatial grid limits hit testing to the current and adjacent cells. Lookup is O(1) on average for a reasonably distributed graph, while worst-case work still depends on bucket density:

// AFTER: O(1) spatial grid lookup
const GRID_CELL_SIZE = 50;
let spatialGrid = new Map<string, D3Node[]>();

const getSpatialKey = (x: number, y: number): string => {
  const cellX = Math.floor(x / GRID_CELL_SIZE);
  const cellY = Math.floor(y / GRID_CELL_SIZE);
  return `${cellX},${cellY}`;
};

const rebuildSpatialGrid = () => {
  spatialGrid.clear();
  for (const node of graphNodes) {
    const key = getSpatialKey(node.x, node.y);
    if (!spatialGrid.has(key)) spatialGrid.set(key, []);
    spatialGrid.get(key)!.push(node);
  }
};

// Hover check: only examine nodes in current + adjacent cells
const getNodesNear = (x: number, y: number): D3Node[] => {
  const candidates: D3Node[] = [];
  for (let dx = -1; dx <= 1; dx++) {
    for (let dy = -1; dy <= 1; dy++) {
      const key = getSpatialKey(
        x + dx * GRID_CELL_SIZE,
        y + dy * GRID_CELL_SIZE
      );
      candidates.push(...(spatialGrid.get(key) || []));
    }
  }
  return candidates;
};

The current index also supports incremental updates during simulation ticks and permits a slightly stale grid under motion pressure. That is an explicit responsiveness tradeoff, not a claim that the graph stopped moving.

Phase 4: GPU Efficiency

For the animated rings, updating a shader parameter avoided rebuilding geometry on the CPU.

ShaderMaterial for Animated Elements

HoloDeck's CPU/memory usage rings around each k8s node (tower) were animated by recreating geometry each frame:

// BEFORE: Geometry recreation each frame
const arcShape = new THREE.Shape();
arcShape.arc(0, 0, radius, startAngle, endAngle, false);
mesh.geometry.dispose();
mesh.geometry = new THREE.ShapeGeometry(arcShape);

The fix uses a custom shader that animates via uniform updates:

// AFTER: Static geometry, animated uniform
precision mediump float;

uniform float uProgress;
uniform vec3 uColor;
varying vec2 vUv;

const float PI = 3.141592653589793;

void main() {
  float angle = atan(vUv.y - 0.5, vUv.x - 0.5);
  float normalizedAngle = (angle + PI) / (2.0 * PI);
  float alpha = normalizedAngle < uProgress ? 1.0 : 0.0;
  gl_FragColor = vec4(uColor, alpha * 0.8);
}

A uniform update keeps the existing geometry and avoids allocating and uploading a replacement mesh.

InstancedMesh for Traffic Particles

HoloDeck's traffic particles used individual meshes:

// BEFORE: Individual mesh per particle
particles.forEach((p) => {
  const mesh = new THREE.Mesh(sphereGeom, mat);
  mesh.position.copy(p.position);
  scene.add(mesh);
});

InstancedMesh renders many copies of a geometry in a single draw call:

// AFTER: Single InstancedMesh for all particles
const trafficMesh = new THREE.InstancedMesh(
  new THREE.SphereGeometry(0.15, 8, 8),
  new THREE.MeshBasicMaterial({ color: 0xffffff }),
  MAX_TRAFFIC
);
trafficMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);

// Update positions via matrix transforms
const dummy = new THREE.Object3D();
particles.forEach((p, i) => {
  dummy.position.copy(p.position);
  dummy.updateMatrix();
  trafficMesh.setMatrixAt(i, dummy.matrix);
});
trafficMesh.instanceMatrix.needsUpdate = true;

Throttling Expensive Operations

Raycasting (detecting what's under the mouse in 3D) is expensive. The original code ran it on every mouse move:

// BEFORE: Raycast on every mouse move
canvas.addEventListener('mousemove', (e) => {
  raycaster.setFromCamera(mouse, camera);
  const hits = raycaster.intersectObjects(scene.children);
  // ...
});

The current HoloDeck throttles hover raycasts to 32 ms (at most about 31 checks per second). That prevents mousemove storms from taking every render slot:

// AFTER: Throttled raycasting
const HOVER_RAYCAST_THROTTLE_MS = 32;
let lastRaycastTime = 0;

canvas.addEventListener('mousemove', (e) => {
  const now = performance.now();
  if (now - lastRaycastTime < HOVER_RAYCAST_THROTTLE_MS) return;
  lastRaycastTime = now;
  // ... raycast
});

Lessons Learned

1. Profile Before Optimizing

Every optimization in this post came from profiling first. The DevTools Performance panel revealed the actual bottlenecks, which weren't always where intuition suggested. The expensive indexOf() call was invisible until the flame chart showed it.

2. Understand the Render Pipeline

Canvas 2D and WebGL have different performance failure modes:

  • Canvas 2D: CPU-bound; per-frame math and allocations show up fast.
  • WebGL: draw-call/state-change bound; fewer objects + batched geometry wins.

This knowledge informed different strategies for each component.

3. Reconcile small changes

Incremental updates reduced work when only a small part of the snapshot changed. They also added bookkeeping: stable IDs, removal logic, and cache invalidation. A full rebuild can still be simpler for small or infrequent updates.

4. Revisit earlier “not worth it” decisions

The first version of this post said a Web Worker was not worth the coordination overhead. That stopped being true as larger scenarios became routine. The current TopologyGraph eagerly creates a worker, builds graph data there, steps the D3 simulation in batches, and sends positions back in transferable Float32Array buffers. It retains a main-thread fallback when worker setup fails.

The lesson is not “always use a worker.” It is to keep earlier tradeoff decisions falsifiable. Once main-thread layout became the larger risk, the boundary changed.

Results I can defend

The repository records support the following results. The June benchmark is an internal FlexDeck artifact; it measures layout construction, not browser frame rate.

  • A deterministic topology benchmark with small, medium, and large fixtures and a checked budget for the large case.
  • A checked-in June 2026 run that recorded 175.1 ms for the large layout scenario against a 193.9 ms budget. That is a dated layout-build result, not a current browser frame-rate guarantee.
  • Runtime HUDs that expose average and maximum frame time, build time, settle time, draw counts, worker messages, and adaptive-quality state.
  • Unit coverage around the HoloDeck render loop, derived state, layout engine, and extracted topology helpers.
  • Adaptive HoloDeck quality that disables bloom and caps pixel ratio after sustained slow frames.

The changes also make subsequent measurements easier:

  • Shared resource patterns prevent allocation churn
  • Cache invalidation points are explicit
  • GPU-bound work is clearly separated from CPU work
  • Worker and main-thread modes are visible instead of implicit

Performance optimization is rarely a single heroic fix. It is a series of changes tied to a named workload and measurement surface. If the capture cannot be reproduced, the number should not become a headline.

Comments

Join the discussion. Be respectful.

Optimizing Real-Time Kubernetes Visualizations | Cody Blevins