RS

Canvas commit graph: virtualizing 100k nodes without lag

Invalid Date min read

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:

  1. Assign "lanes" for each branch
  2. Minimize edge crossings
  3. 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.