Building a Real-Time Canvas Demo: Load Balancer Flow
A practical Canvas API showcase that visualizes a load balancer distributing traffic to app servers.
Summary
A Canvas-powered showcase that animates how a load balancer fans out traffic across app servers.
Key takeaways
- Canvas is great for dense, animated system diagrams.
- Keep rendering and physics separated to stay smooth.
- Batch line strokes before drawing nodes.
- Small, focused demos are perfect for explaining architecture flows.
Building a Real-Time Canvas Demo: Load Balancer Flow
Canvas is a practical way to turn system design concepts into clean, animated visuals. In this article we build a small load balancer flow demo that pulses requests from clients to a balancer, through app servers, and into a shared database. The goal is clarity: show the shape of the system and the direction of traffic without a heavyweight UI stack.
Why Canvas for system diagrams
When a diagram is mostly static, HTML and SVG are great. But when you want animation, pulsing traffic, or thousands of points, Canvas gives you predictable, low-overhead drawing. It is especially useful for teaching flows and relationships where the visualization is the hero and the DOM should stay quiet.
In short, Canvas is a good fit when you want:
- Many moving elements without DOM churn.
- Tight control over draw order and batching.
- A simple, single-surface rendering loop.
Load balancer flow demo
The demo below visualizes a classic flow: clients connect to a load balancer, which fans out traffic to app servers, and each server talks to a database. Pulses show request flow, not real packets, but the shape matches the concept.
If you want to explore more system design content, browse the System Design category.
Renderer structure (the real code)
The demo uses a small GraphRenderer class. It keeps rendering predictable by separating updates from drawing and batching line strokes before nodes. This is the real class used by the in-page demo, trimmed for readability.
GraphRenderer.ts
type GraphNode = {
id: string;
label: string;
x: number;
y: number;
};
type GraphEdge = {
from: number;
to: number;
};
type GraphColors = {
background: string;
edge: string;
edgeGlow: string;
node: string;
nodeBorder: string;
text: string;
pulse: string;
};
const DEFAULT_COLORS: GraphColors = {
background: "#0f1224",
edge: "rgba(148, 163, 255, 0.25)",
edgeGlow: "rgba(129, 140, 248, 0.6)",
node: "#11182d",
nodeBorder: "rgba(148, 163, 255, 0.7)",
text: "#e2e8f0",
pulse: "#a5b4fc",
};
class GraphRenderer {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D | null;
private nodes: GraphNode[];
private edges: GraphEdge[];
private colors: GraphColors;
private width: number;
private height: number;
private animationId: number | null;
private pulseOffsets: number[];
constructor(canvas, nodes, edges) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this.nodes = nodes;
this.edges = edges;
this.colors = DEFAULT_COLORS;
this.width = 0;
this.height = 0;
this.animationId = null;
this.pulseOffsets = edges.map((_, index) => index / Math.max(edges.length, 1));
}
setColors(colors: GraphColors) {
this.colors = colors;
}
resize(width: number, height: number, dpr = 1) {
this.width = width;
this.height = height;
this.canvas.width = width * dpr;
this.canvas.height = height * dpr;
this.canvas.style.width = `${width}px`;
this.canvas.style.height = `${height}px`;
this.ctx?.setTransform(dpr, 0, 0, dpr, 0, 0);
}
start() {
if (this.animationId !== null) return;
const loop = (time) => {
this.updatePhysics(time);
this.drawFrame(time);
this.animationId = requestAnimationFrame(loop);
};
this.animationId = requestAnimationFrame(loop);
}
stop() {
if (this.animationId === null) return;
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
updatePhysics(_time: number) {
// Placeholder for physics updates (springs, repulsion, etc.).
}
drawFrame(time: number, isStatic = false) {
if (!this.ctx) return;
if (!this.width || !this.height) return;
const { width, height } = this;
const ctx = this.ctx;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = this.colors.background;
ctx.fillRect(0, 0, width, height);
ctx.lineWidth = 1.5;
ctx.strokeStyle = this.colors.edge;
ctx.beginPath();
this.edges.forEach((edge) => {
const from = this.nodes[edge.from];
const to = this.nodes[edge.to];
if (!from || !to) return;
ctx.moveTo(from.x * width, from.y * height);
ctx.lineTo(to.x * width, to.y * height);
});
ctx.stroke();
ctx.strokeStyle = this.colors.edgeGlow;
ctx.lineWidth = 2;
ctx.stroke();
if (!isStatic) {
const speed = 0.00012;
this.edges.forEach((edge, index) => {
const from = this.nodes[edge.from];
const to = this.nodes[edge.to];
if (!from || !to) return;
const offset = this.pulseOffsets[index] ?? 0;
const t = (time * speed + offset) % 1;
const x = (from.x + (to.x - from.x) * t) * width;
const y = (from.y + (to.y - from.y) * t) * height;
ctx.fillStyle = this.colors.pulse;
ctx.beginPath();
ctx.arc(x, y, 3.5, 0, Math.PI * 2);
ctx.fill();
});
}
this.nodes.forEach((node) => {
const x = node.x * width;
const y = node.y * height;
ctx.fillStyle = this.colors.node;
ctx.strokeStyle = this.colors.nodeBorder;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, 16, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = this.colors.text;
ctx.font = "12px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(node.label, x, y + 26);
});
}
}
Demo data used by the animation
These nodes and edges are the actual inputs for the demo. They describe a simple load balancer fan-out and a shared database.
const NODES = [
{ id: "clients", label: "Clients", x: 0.12, y: 0.5 },
{ id: "lb", label: "Load Balancer", x: 0.38, y: 0.5 },
{ id: "app-a", label: "App A", x: 0.65, y: 0.25 },
{ id: "app-b", label: "App B", x: 0.7, y: 0.5 },
{ id: "app-c", label: "App C", x: 0.65, y: 0.75 },
{ id: "db", label: "Database", x: 0.9, y: 0.5 },
];
const EDGES = [
{ from: 0, to: 1 },
{ from: 1, to: 2 },
{ from: 1, to: 3 },
{ from: 1, to: 4 },
{ from: 2, to: 5 },
{ from: 3, to: 5 },
{ from: 4, to: 5 },
];
Performance tips you can reuse
A few simple tactics keep Canvas demos smooth even as they get busy:
- Batch your strokes. Draw all edges in one
beginPath()/stroke()pair. - Separate update from draw. Keep physics or layout changes out of the render loop.
- Cache static layers. If something never changes, render it once and reuse it.
- Adapt to device pixel ratio. Resize the canvas using
dprso text stays crisp.
Conclusion
Canvas is a great way to showcase system flows without overcomplicating the DOM. A small renderer and a clean dataset are enough to tell the story. If you want to explore deeper system design topics, start with the System Design Simulator: Uber-like Architecture Walkthrough.
Read next
View allMastering React Hooks in 2025
A deep dive into the latest patterns and best practices for using React Hooks in high-performance applications.
System Design Simulator: Uber-like Architecture Walkthrough
A behind-the-scenes look at the new System Design Simulator: how decisions shape the diagram, metrics, and tradeoffs.
System Design Interview: From Zero to Hero
Learn how to approach complex system design problems and communicate your architecture decisions effectively.