Optimizing Real-Time Kubernetes Visualizations: From 25ms to 12ms Per Frame
December 25, 2025·7 min read
Real-time Kubernetes dashboards sound straightforward until you point them at real cluster data. In FlexDeck (my internal dashboard), a force-directed topology view plus a 3D “HoloDeck” scene looked great with toy inputs, then turned into a slideshow once we hit ~14–15 Kubernetes worker nodes and ~150 visible pods. In graph terms, that’s roughly ~200 rendered vertices (k8s nodes + services + pods) moving every frame.
This post is a field report on getting back to a stable 60fps budget by removing accidental O(N×M) work, batching WebGL draw calls, and cutting allocation churn. The details come from a Kubernetes dashboard, but the patterns generalize to any real-time graph visualization.
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.
- Profile every change; trust the flame chart, not intuition.
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 numbers
Frame-time numbers below are from Chrome DevTools captures on a reference machine and should be treated as directional. The goal isn’t “exactly 12ms” everywhere, it’s understanding which changes move you from “CPU/GC bound” to “mostly GPU bound.”
Why do all this?
Because once a visualization becomes part of how you operate a system, performance stops being “nice to have” and becomes correctness-adjacent. If pan/zoom stutters, hover hit-testing lags, or the UI freezes during layout, you start avoiding the tool, right when you need it most.
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
It worked fine on demo data. Then I kept using it, pointed it at a real cluster, and had the moment of “either delete this… or make it not technically embarrassing.” Everything below is what “make it not embarrassing” looked like: measure first, then fix what the flame chart actually proves is hot.
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 beautifully for small clusters. But as we tested with production-scale data, problems emerged:
- UI freezing during initial graph layout
- Dropped frames during zoom/pan interactions
- Sluggish hover response times
- Memory leaks causing eventual browser crashes
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
The biggest performance killers are often hidden in innocent-looking code.
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;
Impact: 5-10ms saved per frame on large clusters.
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.
Impact: 50-75% reduction in line drawing overhead, from 150+ draw calls to 3 (one per status color).
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 used the same sphere geometry and similar materials. Creating them individually was wasteful:
// BEFORE: New geometry per pod
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(0.5, 16, 16), // Created 150 times!
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.SphereGeometry(0.5, 12, 12)),
// ... 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));
The Memory Leak That Bit Us
Shared resources introduced a subtle bug. When the component unmounted, we disposed everything:
// 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();
});
But shared resources were being disposed multiple times, and worse, they weren't recreated on remount. The fix tracks shared resources separately:
// 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
The fastest code is code that doesn't run. Caching is the art of avoiding redundant computation.
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 biggest win wasn’t a clever “data key”, it was switching to a reconcile model: keep a Map of scene objects keyed by stable IDs (UID/name), then add/update/remove only what changed.
When you do need a guard to skip work, make it correct (not “sample first/last and hope”). In practice, this is easiest if your data layer emits a monotonic revision (watch event counter, snapshot ID, etc.):
// 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 O(1) Hover Detection
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 provides O(1) average-case lookup:
// 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 grid is invalidated when graph nodes move (during force simulation) and rebuilt lazily on next hover check.
Phase 4: GPU Efficiency
The best optimization moves work from CPU to GPU.
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);
}
Updating a uniform is nearly free compared to recreating geometry.
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);
// ...
});
Throttling to 64ms (~15fps) is usually fine for hover, and it prevents “mousemove storms” from stealing time from rendering:
// AFTER: Throttled raycasting
const RAYCAST_THROTTLE_MS = 64;
let lastRaycastTime = 0;
canvas.addEventListener('mousemove', (e) => {
const now = performance.now();
if (now - lastRaycastTime < 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. Incremental Updates Beat Full Rebuilds
The instinct to "clear and rebuild" on data changes is almost always wrong for visualizations. Tracking what changed and updating incrementally (adding new items, removing deleted ones) is more complex but dramatically faster.
4. Know When NOT to Optimize
We considered moving D3’s force simulation to a Web Worker. D3 force runs on the main thread; a Worker can help for very large graphs, but our biggest wins came from reducing hot-path work (lookups, allocations, rebuilds) and keeping render and simulation concerns decoupled. For this case, the serialization + coordination overhead wasn’t worth it.
Results
| Component | Before | After | Improvement |
|---|---|---|---|
| TopologyGraph (~200 graph nodes) | ~25ms/frame | ~12ms/frame | 52% |
| HoloDeck (150 pods) | ~20ms/frame | ~10ms/frame | 50% |
Both components now maintain solid 60fps with headroom for larger clusters. More importantly, the codebase is now structured to make future optimizations easier:
- Shared resource patterns prevent allocation churn
- Cache invalidation points are explicit
- GPU-bound work is clearly separated from CPU work
Performance optimization is rarely a single heroic fix. It's a series of small improvements, each informed by measurement, that compound into significant gains.
Related Articles
Comments
Join the discussion. Be respectful.