Skip to main content
graphhub/engineering
Engineering Case Study

How GraphHub was built

A walkthrough of the architecture, design decisions, and engineering tradeoffs behind an interactive codebase graph visualizer — from GitHub API to canvas pixels.

3

cache layers

4

parsed languages

60fps

canvas render

0ms

warm start

01 — THE PROBLEM

Reading code is not the same as understanding it

Every developer has opened an unfamiliar repo and spent the first hour clicking through folders trying to build a mental model of the structure. GitHub's file tree forces you to navigate linearly. Search requires you to already know what you're looking for. Neither gives you the shape of the codebase.

GraphHub solves this by treating a repository as a graph problem. Folders, files, functions, and classes become nodes; imports and containment relationships become edges. The result is a living map you can explore rather than a hierarchy you navigate.

The constraint we set ourselves: no setup, no tokens, no configuration. Paste a URL — or just swap github.com for graphhub.dev — and the graph appears within seconds.

02 — ARCHITECTURE

End-to-end in one request

The entire pipeline — from GitHub API to rendered graph — is triggered by a single GET to /api/graph/:owner/:repo. The response is a serialised graph that the client can simulate and render without further API calls.

Browser
  │
  └─ GET /api/graph/:owner/:repo
       │
       ├─ getLatestSHA()        ← one GitHub API call to get commit SHA
       │    │
       │    └─ SHA unchanged?   ← check server cache (node-cache, 24hr TTL)
       │         └─ HIT  ──────────────────────────────────────→ return cached
       │         └─ MISS ↓
       │
       ├─ getFileTree()         ← GitHub Trees API (recursive)
       │    └─ filter to source files only (size < 500KB, known extension)
       │
       ├─ getFileContentsBatch()  ← parallel fetch, 10 at a time
       │    └─ per-file cache check (fileKey = sha + path, 24hr TTL)
       │
       ├─ parseAll()            ← AST-based extraction per language
       │    └─ imports, functions, classes, exports → ParsedFile[]
       │
       └─ buildGraph()          ← nodes + edges + cluster assignments
            │
            ├─ cache.set(graphKey, ..., 24hr)
            └─ return { graph, meta }

Three things keep this fast on repeat visits. The server cache is keyed by SHA, so it's safe to hold for 24 hours — the graph only changes when the repo changes. The HTTP response carries Cache-Control: s-maxage=3600, stale-while-revalidate=86400, so CDN and browser caches serve it without hitting the origin. And the client stores the graph in localStoragefor 30 minutes, so navigating back to a repo you've already visited is instantaneous — zero network, zero loading state.

03 — FRONTEND ENGINEERING

Canvas, not DOM

The graph renders to a <canvas> element, not SVG or DOM nodes. At 500+ nodes, DOM-based graph libraries start dropping frames because the browser is reconciling thousands of elements per tick. A canvas is a pixel buffer — drawing 2,000 circles and lines per frame takes roughly 2ms, well within the 16ms frame budget for 60fps.

The tradeoff is real: no native hover states, no CSS transitions, no devtools inspection of individual nodes. We compensate with a d3-quadtree for O(log n) hit-testing, manual highlight state tracked in refs, and keyboard navigation with tabIndex=0 on the canvas element.

Progressive detail rendering: at zoom level k < 0.3, only folder nodes render. At k < 0.7, functions and classes are hidden. Labels appear only above k = 0.6. The user always sees a readable graph regardless of zoom level.

The render loop is a standard requestAnimationFrame tick. It reads node positions from the D3 simulation's mutable node array, runs the quadtree update, draws edges then nodes then labels (painter's algorithm), and schedules the next frame only if the simulation is still active. When the simulation cools (alpha < 0.001), the loop stops entirely. Interaction events (hover, select, drag, zoom) call requestRedraw() which re-enters the loop for exactly one frame — the canvas only repaints when something changes.

Force simulation physics

D3's force simulation runs in-process on the client. The physics are tuned per node type to produce a visual hierarchy that matches the code structure:

Node type    Charge      Collision radius   Role in layout
─────────────────────────────────────────────────────────
folder       −1,400      130px              Structural anchors
file         −800        42px               Cluster around folders
function     −500        32px               Dense fill within files
class        −500        32px               Same as function

Edge type    Distance    Strength
──────────────────────────────────────
contains     120px       0.45
import       180px       0.20

Folders carry a strong negative charge so they push each other to opposite ends of the canvas. Files are attracted to their parent folders via containsedges but repel each other enough to remain distinct. Functions and classes are lightly repelled — they cluster within their file's gravity well.

A custom cluster force pulls file nodes toward the centroid of their cluster on every tick, reinforcing the grouping without hard constraints. Node dragging pins a node's fx/fy coordinates and reheats the simulation; releasing unpins and lets it settle.

Three-layer caching

Layer          Where        TTL        Miss cost
────────────────────────────────────────────────────────────
Client         localStorage  30 min     falls to layer 2
HTTP           CDN/browser   1 hr SWR   falls to layer 3
Server         node-cache    24 hr      re-runs full pipeline

server cache: max 500 entries, SHA-keyed (auto-invalidates on push)
in-flight dedup: concurrent cold-cache requests share one Promise

04 — BACKEND ENGINEERING

Parsing without tokens

The parsing pipeline extracts structure from source files without executing them or requiring a language server. For each file, a language-specific parser reads the raw text and produces a ParsedFile containing imports, functions, classes, and exports.

We parse JavaScript, TypeScript, Python, and Go with reasonable accuracy across common patterns. The parser intentionally handles the 90% case well rather than attempting perfect coverage — edge cases like deeply dynamic imports or metaprogramming produce no nodes rather than wrong ones.

ParsedFile {
  path:      string
  language:  "javascript" | "typescript" | "python" | "go" | ...
  imports:   ResolvedImport[]   ← raw string + resolved path + external flag
  exports:   string[]
  functions: FunctionDef[]      ← name, line, exported flag
  classes:   ClassDef[]         ← name, line, methods[]
}

Import resolution is the most complex part. A relative import like ../../utils/parse needs to resolve to an actual file path in the tree. We do this without a file system by normalising the path relative to the importing file, then checking whether a matching path exists in the tree (with and without extension). External imports (packages, not relative paths) get their own node type rather than resolving.

Rate limit handling

The GitHub public API allows 60 unauthenticated requests per hour. Fetching a large repo can burn through this quickly. We handle it in two ways: the server cache means most requests never touch GitHub at all, and getFileContentsBatch sends files in parallel batches of 10, detecting 429 responses and surfacing a typed rate_limited error with a retryAfter timestamp rather than silently returning a partial graph.

05 — DESIGN SYSTEM

Decisions behind the visuals

Warm neutrals, not pure black

Pure #000000 backgrounds feel harsh on a developer tool used for hours at a time. We use #0c0c0e (dark) and #fafaf9 (light) — fractionally warm grays that read like paper. The text and background values carry a very slight warm tint so they sit in the same colour temperature, preventing the eye-strain that comes from pure cool-on-warm contrast.

CSS custom properties, not Tailwind classes

All colours are defined as CSS custom properties on :root and overridden on .dark. This means theme switching is one classList.toggle and a 200ms CSS transition — no JavaScript recalculation, no class name juggling per component. The graph canvas reads colour values at draw time via getComputedStyle, so it automatically inherits the active theme.

An inline <script> in the document head reads localStorage and sets .dark before first paint, preventing the flash of wrong theme that most dark-mode implementations suffer from.

Graph node colours

Each node type has a fixed colour chosen for three properties simultaneously: distinguishability from other node types, readability against both the light and dark backgrounds, and coherence with the warm neutral palette.

Folder#7c7c8a / #9696a6
File#8b9dc4 / #a2b8d8
Function#8aab96 / #9ec4ae
Class#c4a96e / #d4bc82

06 — TRADEOFFS

Every decision has a cost

These were the four decisions with the highest impact. Each one was a genuine fork in the road with real costs on both sides.

Canvas rendering over SVG

CHOSE THIS

  • +Handles 10,000+ nodes at 60fps
  • +Single DOM element regardless of graph size
  • +Full control over render order and blending

ACCEPTED THIS COST

  • No native accessibility (mitigated: ARIA + keyboard nav)
  • No CSS hover states (mitigated: quadtree hit-testing)
  • Harder to debug (no devtools element inspection)

Why: SVG starts dropping frames above ~500 nodes. The canvas path is more complex to build but it's the only one that scales.

Client-side force simulation

CHOSE THIS

  • +Node dragging and pinning work without a round-trip
  • +No need to serialise live simulation state
  • +Users can explore at their own pace after the graph loads

ACCEPTED THIS COST

  • Cold start takes 2–5s for large repos while simulation settles
  • Layout differs between visits (simulation is non-deterministic)

Why: Server-side simulation would add significant API complexity and still require a client-side re-hydration pass. The localStorage cache eliminates the cold-start cost on warm visits.

In-process node-cache over Redis

CHOSE THIS

  • +Zero network latency — reads are memory lookups
  • +No external dependency, no infrastructure cost
  • +500-key cap prevents unbounded memory growth

ACCEPTED THIS COST

  • Lost on server restart or cold start
  • Not shared between multiple instances

Why: For this workload, restarts are infrequent. The HTTP Cache-Control layer covers cold starts via CDN. Adding Redis would be premature optimisation that complicates deployment.

SHA-keyed cache invalidation

CHOSE THIS

  • +Cache is always correct — stale data is impossible by design
  • +Safe to cache aggressively (24hr server, 1hr HTTP, 30min client)
  • +No background refresh jobs needed

ACCEPTED THIS COST

  • One GitHub API call per request to get the latest SHA
  • Slight overhead (~40–80ms) even on full cache hits

Why: The alternative — time-based expiry — would occasionally serve a graph that doesn't match the current repo state. Correctness is worth the extra round-trip.

07 — INTENTIONAL CUTS

What we chose not to build

Scope discipline is part of engineering. These features were considered and deliberately left out of v1:

WebGL rendering

Canvas handles current scale comfortably. WebGL introduces shader complexity and a ~30KB runtime for no user-visible benefit at <50k nodes.

Commit diff view

Showing structural changes across commits requires diffing two full graphs and a meaningful visual vocabulary for additions, removals, and moves.

Authentication / saved graphs

State storage adds session management, a database, and auth infrastructure. The URL is already the save state — shareable without a login.

Server-side layout

Pre-computing node positions on the server would require serialising simulation state and sending x/y for every node. Client-side simulation stays interactive.

Real-time collaboration

Multiplayer cursor presence and shared selection require WebSockets, conflict resolution, and a presence server. Not a v1 problem.

See it in action

Try it on any public GitHub repository.