Canvas commit graph: virtualizing 100k nodes without lag
The commit graph is the central UI in any Git client. But if the repository contains 50k+ commits (normal for mature projects), standard approaches with DOM or SVG don't work: thousands of DOM nodes = freezes on scroll.
Why not DOM
The first version of GitBor drew the graph with <div> + absolute positioning. At 500 commits — fine. At 5000 — scroll started lagging. At 20000 — the browser refused to render.
Reason: DOM nodes are expensive. Every <div> is an object in memory participating in layout, paint, and composite. Rendering 20000 divs is asking the GPU to repaint 20000 layers on every scroll frame.
Canvas 2D + virtualization
The solution — Canvas 2D with virtualization. Only draw what's visible:
class CommitGraphRenderer {
private visibleRange: { startY: number; endY: number };
private commitHeight = 32;
render(ctx: CanvasRenderingContext2D, commits: CommitNode[]) {
const startIdx = Math.floor(this.visibleRange.startY / this.commitHeight);
const endIdx = Math.ceil(this.visibleRange.endY / this.commitHeight);
for (let i = startIdx; i <= Math.min(endIdx, commits.length - 1); i++) {
this.drawCommit(ctx, commits[i], i);
}
}
}
Regardless of total commit count, each frame draws at most 30-40 (what fits in the viewport).
Layout algorithm
The hardest part — horizontal branch positioning. A Git graph is a DAG (directed acyclic graph). We need to:
- Assign "lanes" for each branch
- Minimize edge crossings
- Keep it compact
The algorithm works incrementally: when new commits load, only new lanes are computed; existing ones stay untouched.
type CommitNode = {
hash: string;
parents: string[];
lane: number;
y: number;
color: string;
};
function assignLanes(commits: CommitNode[]): void {
const activeLanes: Map<string, number> = new Map();
let nextLane = 0;
for (const commit of commits) {
if (activeLanes.has(commit.hash)) {
commit.lane = activeLanes.get(commit.hash)!;
activeLanes.delete(commit.hash);
} else {
commit.lane = nextLane++;
}
for (let i = 0; i < commit.parents.length; i++) {
if (!activeLanes.has(commit.parents[i])) {
activeLanes.set(commit.parents[i], i === 0 ? commit.lane : nextLane++);
}
}
}
}
Hit-testing
Canvas is pixels, not DOM elements. Clicking a commit = custom hit-test:
function hitTest(x: number, y: number, commits: CommitNode[]): CommitNode | null {
const idx = Math.floor(y / COMMIT_HEIGHT);
if (idx < 0 || idx >= commits.length) return null;
const commit = commits[idx];
const cx = LANE_WIDTH * commit.lane + LANE_WIDTH / 2;
const cy = idx * COMMIT_HEIGHT + COMMIT_HEIGHT / 2;
const dist = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2);
return dist <= NODE_RADIUS ? commit : null;
}
Edges
Edges between commits are drawn as bezier curves. Merge commits (2+ parents) draw additional lines to secondary parents. Edge color matches the lane color.
Result
- 100k commits: scroll at 60fps
- First render: <16ms (single frame)
- Memory: ~40MB for 100k (data only, Canvas buffer is fixed)
- Interactivity: hover, click, context menu, selection — all via hit-test
For comparison: GitHub Desktop on the same repository (Linux kernel, ~1.2M commits) shows only the last 1000 and doesn't draw a graph. GitKraken draws one but lags after 10k.