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.
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.
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.