SysDesignPrep.com
System design interview question

Design GitHub

Host 400 M git repositories on replicated file servers, serve 100 k fetches a second, and run pull requests, forks and webhooks on top.

Last updated 2026-10-26. Difficulty: hard. Patterns: git, replication, quorum, content-addressing, thundering-herd. Reported at Microsoft, Atlassian, Google, Meta, Amazon, Stripe.

Walk through a strong candidate's answer, turn by turn.

The interviewer asks, the candidate answers and draws, and you press Next. Skip to any stage or key moment, and ask the AI Mentor anything along the way.

Or sit it in the style of Microsoft · Atlassian · Google · Meta, run as that company’s round is reported to go.

The short answer

How to design GitHub in a system design interview, in five steps:

  1. Scope it. Clone, fetch and push over HTTPS and SSH, browse code and history in the web UI, pull requests, forks, webhooks and CI triggers, code search, large files.
  2. Set the targets. No acknowledged push is ever lost: 2 of 3 replicas before ack; Refs never go backwards: linearizable per ref; Git availability: 99.95 % reads and writes; Latency: no-op fetch < 100 ms · push ack p99 < 1 s.
  3. Do the numbers. Git operations per second: ~35 k/s avg · ~100 k/s peak; Pushes per second: ~350/s avg · ~1 k/s peak; Repository sizes and storage: ~10 PB unique · ~30 PB on disk.
  4. Draw the design. LFS & bundle store, CI runners, Web & API, Git frontends, Webhook delivery, Metadata DB, Repo router, Pack cache, File servers.
  5. Go deep where it is hard.
    • Repositories as git on local disk → Bare git repositories on local NVMe, placed by fork network, three replicas.
    • Replicating a push: three replicas and a vote → Stream to all three replicas, vote on the ref transaction, commit at two of three.
    • Making clones and fetches cheap → Reachability bitmaps, verbatim delta reuse, protocol v2, continuous repacks.
    • The CI herd and the one hot repository → A single-flight pack cache for identical requests, plus bundles on a CDN for hot repositories.
    • Forks, alternates and the network repository → One shared object store per fork network; members read it through alternates.
    • Pull requests: git computes, MySQL remembers → Metadata in MySQL; diffs and merges by git on the file servers; mergeability recomputed lazily in the background.

Functional requirements

  • Clone, fetch and push over HTTPS and SSH. Standard git clients speaking the smart protocol, version 2 where the client supports it. Authentication by token or SSH key, branch protection enforced on push. Anything that works against git itself must work against us.
  • Browse code and history in the web UI. Files at any commit, history, blame and compares, read straight from the repository. A popular README is viewed thousands of times a minute, so rendered output is cached by object id.
  • Pull requests. Propose merging a branch, often from a fork, into another: a diff, line comments, reviews, required checks, and merge, squash or rebase. Whether it merges cleanly is shown before anyone presses the button.
  • Forks. One click and instant, even for a 5 GB repository, and a popular repository has tens of thousands of them. A fork must not copy its parent’s history.
  • Webhooks and CI triggers. Every push, pull request and review becomes an event delivered to subscribed endpoints and CI systems within seconds, signed, at least once.
  • Code search. Substring and regex search across every repository the user can read: all public ones plus the private ones they have access to, and nothing else.
  • Large files. Models, videos and game assets through Git LFS: git stores a small pointer and the bytes go to object storage. Plain git objects over 100 MB are refused.
  • Out of scope. Issues and project boards (plain rows in the metadata database), the CI runners and deployments themselves (see the Code Deployment System), packages, Pages and Copilot. Webhook delivery uses the standard machinery from the webhooks guide; here we cover what is specific to git.

Non-functional requirements

  • No acknowledged push is ever lost (2 of 3 replicas before ack). A push that printed “main -> main” and then vanished in a failover breaks CI, deploys and trust. This is the requirement that drives the hardest trade-off: synchronous writes to a quorum cost push latency, in exchange for never losing or reordering a ref.
  • Refs never go backwards (linearizable per ref). Once anyone has seen main at commit X, no replica may serve an older main. CI, deploys and every cache key on the commit a ref points to.
  • Git availability (99.95 % reads and writes). Reads survive losing two of a repository’s three replicas and writes survive losing one. A dead file server must not take a single repository offline.
  • Latency (no-op fetch < 100 ms · push ack p99 < 1 s). No-op fetches are what CI pollers and IDEs do all day, so they must be cheap for us as well as fast for them: one round trip and a short ref list. Push latency is dominated by the replica vote, about four round trips.
  • Scale (400 M repos · ~100 k git ops/s peak). Clones and fetches outnumber pushes about 100 to 1, and CI and automation make most of them. Repositories range from a few KB to monorepos of 100 GB.
  • Hot repositories stay contained (one repo cannot starve its neighbours). A repository cloned a million times a day, or fetched by 2,000 CI jobs in one minute, shares its three file servers with millions of other replicas. Its load must land on caches, not on its replicas.
  • Private means private (zero cross-repository reads). Private contents never leak through forks, caches, code search or webhooks. Every git request, cached pack and search hit is checked against the caller’s permission on that repository.
  • Event freshness (webhook p95 < 10 s after push). CI that starts a minute late is a minute added to every developer’s loop. At least once and signed; order is not guaranteed.

Back-of-envelope estimates

  • Git operations per second: ~35 k/s avg · ~100 k/s peak. Assume 3 B clones and fetches a day, about 60 % from CI and automation: 3 × 10⁹ ÷ 86 400 ≈ 35 k/s. US and European working hours overlap into a peak of about 3× ≈ 100 k/s.
  • Pushes per second: ~350/s avg · ~1 k/s peak. 30 M pushes a day ÷ 86 400 ≈ 350/s, about 1 k/s at peak, so reads outnumber writes ~100 : 1. Each push is a few round trips to three replicas: ~3 k replica writes a second over 1,500 servers. Write throughput is not the problem; write consistency is.
  • Repository sizes and storage: ~10 PB unique · ~30 PB on disk. Heavy-tailed: median under 1 MB, p99 around 1 GB, a few hundred monorepos of 10–100 GB. Assume a mean of 25 MB once forks share their parent’s objects: 400 M × 25 MB = 10 PB, × 3 replicas = 30 PB, growing roughly 30 % a year.
  • File servers: ~1,500 servers · ~800 k replicas each. NVMe servers with ~30 TB each, kept ~70 % full to leave room for repacks and moves: ~20 TB of repositories per server. 30 PB ÷ 20 TB = 1,500, and 400 M × 3 ÷ 1,500 = 800 k replicas per server, almost all tiny and idle. A server’s load comes from its few busy ones.
  • CPU to serve git at peak: ~8 k cores. Assume 97 % of operations are incremental fetches at ~20 ms of CPU (ref list, negotiation, a small pack) and 3 % are clones at ~2 CPU-s with bitmaps: 97 k × 0.02 + 3 k × 2 ≈ 8 k cores, about 17 % of 1,500 × 32. Without reachability bitmaps the server side of a kernel clone took 44 s instead of 1.6 s, so clones alone would need ~130 k cores: three times the fleet.
  • One hot repository: ~70 Gbit/s at peak. 1 M full clones a day of a 250 MB pack, peaking at 3×: 1 M × 250 MB × 3 ÷ 86 400 ≈ 8.7 GB/s ≈ 70 Gbit/s, plus ~70 cores of pack CPU. Its three file servers have 75 Gbit/s and 96 cores between them, shared with ~2.4 M other replicas. One repository can saturate its hosts, so its clones must be served from somewhere else.
  • CI herd after one push: ~130 cores · ~13 GB/s for a minute. A push to a busy monorepo starts 2,000 jobs that each shallow-clone the new commit within 60 s, a 400 MB pack costing ~4 CPU-s to build: 2,000 × 4 ÷ 60 ≈ 133 cores, and 2,000 × 400 MB ÷ 60 ≈ 13 GB/s. Every one of those requests is identical, so building the pack once turns 8,000 CPU-seconds into 4.
  • Webhook deliveries: ~6 k/s avg · ~20 k/s peak. 30 M pushes plus ~170 M pull request, review, comment and check events = 200 M events a day, each delivered to ~2.5 endpoints (repository hooks, organisation hooks, installed apps): 500 M a day ≈ 5.8 k/s, bursting to ~20 k/s. Each push delivery to a CI system turns into fetches, which is how webhooks become the CI herd.

Components

  • LFS & bundle store (S3-class · behind a CDN): Holds Git LFS objects keyed by their SHA-256, and pre-built clone bundles of hot repositories. Clients fetch both from the CDN with signed URLs, so gigabyte binaries and a million full clones a day never touch a file server. These are large, immutable, read whole and never deltified: exactly what object storage is good at, and exactly what git objects are not.
  • Developer (git CLI · browser · IDE): Runs standard git over SSH or HTTPS and uses the web UI and API for pull requests and browsing. The client does real work: it negotiates which objects it already has, verifies every object it receives against its hash, and with shallow or partial clone decides how much history it wants.
  • CI runners (Actions · Jenkins · Buildkite): Receive push and pull request webhooks and fetch the new commit, thousands of jobs at once for a busy repository. Ephemeral runners make shallow clones of exactly that commit, so their requests are byte-identical, which is what makes them cacheable. Most git reads come from machines like these, not from people.
  • Web & API (Rails · REST and GraphQL): The UI and API for everything that is not the git protocol: pull requests, reviews, forks, settings, search. Repository contents come from the file servers over an internal RPC (tree at a commit, blob, diff, merge), never by cloning. Rendered blobs and diffs are cached by object id, which can never go stale because an object never changes.
  • Git frontends (SSH · smart HTTP · auth): Terminate SSH and HTTPS, authenticate the key or token, check the caller’s permission on the repository and its branch protection rules, then hand the git stream to the router. Stateless. They also cap concurrent pack builds per repository and per user, the last line of defence when one repository is hammered.
  • Webhook delivery (per-endpoint queues · HMAC): Turns each event into one delivery per subscribed endpoint (repository hooks, organisation hooks, installed apps), signs it with that endpoint’s secret and POSTs it with a 10 s timeout. Retries back off per endpoint, so one dead CI server never delays anyone else. About 6 k deliveries a second on average.
  • Metadata DB (MySQL · partitioned by domain): Users, permissions, repositories and their fork networks, pull requests, reviews, comments, check results and webhook subscriptions. Nothing from inside git lives here except the commit ids that rows point at. GitHub has written that it runs on 1,200+ MySQL hosts in 50+ clusters, split by feature domain (2023).
  • Repo router (routes · votes · repair): Knows where every repository lives (repository → fork network → three file servers) and which replicas are in sync. Reads go to the least-loaded in-sync replica; pushes are streamed to all three and committed when two agree on the ref update. It also owns replica health: it marks replicas out of sync, re-replicates networks that fell below three, and moves networks to rebalance disk and CPU.
  • Pack cache (keyed by request · single-flight): Generated packs keyed by a hash of (repository, wants, haves, depth or filter, capabilities), on a fleet of cache servers with NVMe and fast NICs. Concurrent identical requests wait for the one being built and stream it as it is written. It takes both the CPU and the bytes of a CI herd off a repository’s three replicas; an entry is only ever served for the repository that produced it.
  • File servers (git on local NVMe · ×3): About 1,500 servers, each holding ~800 k repository replicas as ordinary bare git repositories on local disk, grouped by fork network. They run git itself: receive-pack for pushes, upload-pack and pack-objects for fetches, merge-tree and diff for the web. Each also repacks its busy repositories continuously (geometric repacks, multi-pack indexes, bitmaps), which is what keeps fetches cheap.
  • Event bus (Kafka · keyed by repository): Ref-update events (repository, ref, old, new, pusher, push id) relayed from the replicas’ committed ref journals, plus pull request and review events from the web tier. Keyed by repository so one repository’s events stay in order. Webhooks, PR workers, code search and notifications each consume it at their own pace.
  • PR workers (background merges · diffs): When a pull request’s head or base moves, ask a file server for the test merge (git merge-tree with merge-ort, no working copy), store it as refs/pull/N/merge and record mergeable or conflicting in MySQL. Debounced and prioritised by who is looking, because one push to a busy main invalidates thousands of open pull requests at once.
  • Code search (ngram index · sharded by blob id): A separate index of default-branch contents, fed by push events and reading only the changed blobs. Documents are keyed by blob id, so a file shared by 10,000 forks is indexed once with a list of repositories. Each query carries the set of repositories the caller may read and is filtered inside the index. GitHub’s Blackbird shards by blob id for exactly this reason.

User flows

  1. A developer pushes to main. The core write. Objects are the easy part because they are content-addressed and can be written anywhere; the only thing that needs agreement is the ref moving from old to new, and two of three replicas must agree before the client hears “ok”.
    1. If the commits include LFS-tracked files, the client uploads those bytes to object storage before git sends anything. The git-lfs pre-push hook asks the batch API which objects are missing and uploads them with presigned URLs straight to object storage. Git then pushes only ~130-byte pointer files, so the repository, its clones and its repacks stay small no matter how many 2 GB assets it tracks.
    2. The client opens receive-pack over SSH; the git frontend authenticates the key and checks write permission. Keys, tokens and permissions are read from MySQL replicas and cached for seconds. Branch protection (no force pushes to main, required reviews) can only be judged once the update commands arrive, so the frontend passes the rules along and they are enforced in the prepare phase.
    3. The router looks up the repository’s replica set and opens a receive session on each of its three file servers. acme/api → network 5512 → fs-0412, fs-1093, fs-0207, all in sync. A replica already marked out of sync is left out and repaired later; if fewer than two in-sync replicas are reachable, the push is refused rather than accepted without a quorum.
    4. The client sends its ref update commands and a packfile; the router streams the same bytes to all three replicas, which verify the objects into quarantine. Each replica runs index-pack, which checks that every object hashes to its name, resolves deltas against objects it already has, and fscks what is new. Quarantined objects are invisible to readers until the push commits, and a failed push leaves nothing behind. Writing objects is idempotent: a replica that already had some of them is unaffected.
    5. Prepare: each replica locks the ref, checks it still points at the old value, and votes with a hash of the exact update. This is git’s own reference transaction (update-ref --stdin with prepare), with the reference-transaction hook reporting what is about to change. If main moved since the client read it, the old-value check fails identically on every replica and the push is rejected as non-fast-forward, which is ordinary git behaviour.
    6. With two matching votes the router tells the replicas to commit; a replica that disagreed or timed out is marked out of sync for this repository. Commit moves the ref and publishes the quarantined objects together on each replica. Each replica then reports a checksum over all its (ref, value) pairs, updated by XOR on every change, so equal checksums mean identical refs. The out-of-sync mark is written before the client is acknowledged, so no reader can be sent to a replica that missed the push.
    7. The client sees “main -> main”, and the committed update is relayed to the event bus. Each replica appends the update to a local ref journal as part of the commit, and a relay on the replica publishes from that journal. A router crash right after the ack cannot lose the event. All three replicas may publish the same push, so consumers dedupe on push id.
  2. An everyday fetch, and a file in the browser. The core read, and almost all of the load. A fetch is a negotiation followed by pack generation; protocol v2 keeps the negotiation small, and bitmaps plus reuse of stored deltas keep the pack cheap.
    1. The client connects with protocol v2 and asks only for the refs it cares about. In the original protocol the server opens by advertising every ref, and a busy repository has 500,000 of them (each pull request adds refs/pull/N/head and /merge): tens of MB on every no-op fetch. In v2 the client sends ls-refs with prefixes such as refs/heads/ and refs/tags/, and only those come back. Read permission on a private repository is checked first.
    2. The router sends the request to the least-loaded in-sync replica. Any in-sync replica can serve a read, so a repository has three times the read capacity of a single copy. “In sync” means the replica’s ref checksum matches the last committed one; a replica that missed a push would otherwise show a main that had gone backwards.
    3. Client and server negotiate: the client names the commits it wants and the commits it already has. An everyday fetch has yesterday’s main, so one round of haves finds the common base and the answer is a few new commits. A fresh clone has no haves; a shallow clone says deepen 1, and a blobless clone says filter blob:none and fetches file contents later, on demand.
    4. The file server works out which objects to send from reachability bitmaps, and reuses deltas already stored in its packs. Counting objects by walking every commit and tree is the expensive part. A bitmap per selected commit records which objects it can reach, so “reachable from wants minus reachable from haves” is a few bitwise operations. Objects already stored as deltas against something the client will also receive are copied out verbatim, with no recompression.
    5. The pack streams back; the client verifies every object against its hash and updates its remote-tracking refs. Because objects are named by their content, corruption anywhere on the path is detected by the client, not trusted. An everyday fetch of a busy repository is a few hundred KB and about 20 ms of server CPU.
    6. In the browser, viewing a file at a commit is an RPC to the same replicas, cached by blob id. The web tier resolves the branch to a commit (one ref read), walks the path through the trees, and reads the blob. The rendered HTML is cached under the blob id plus the renderer version, with no invalidation ever, because a blob id names its content forever. The only thing that changes is which commit a branch points to.
  3. A push triggers 2,000 CI jobs. The scale-breaking case. Within a minute of one push to a busy monorepo, 2,000 runners ask for the same new commit; served naively that needs more CPU and bandwidth than the repository’s three file servers have. The requests are identical, which is what makes them cheap.
    1. Webhook delivery sends the signed push event to the CI system’s endpoint. Deliveries are queued per endpoint with a 10 s timeout and retried with backoff, so a slow CI endpoint only delays its own events. The receiver verifies the HMAC and dedupes on the delivery id, as the webhooks guide describes.
    2. The CI system starts 2,000 jobs, and each fresh runner asks for a shallow clone of exactly that commit. Ephemeral runners start empty, so every request is the same: want 7b21…, depth 1, no haves. 2,000 of them in 60 s, each a 400 MB pack costing ~4 CPU-seconds, would need ~130 cores and 13 GB/s from three servers that have 96 cores and ~9 GB/s between them, and that serve millions of other replicas.
    3. The router turns each request into a pack cache key and looks it up. The key hashes (repository id, wants, haves, depth, filter, and the capabilities that change the bytes). The repository id is part of the key, so an entry is never served for another repository, even one in the same fork network. The first request misses and takes a build lease; the other 1,999 find the build in progress.
    4. One in-sync replica builds the pack once, and the router tees it into the cache as it streams. Waiters read the cache file as it grows, so the second runner receives bytes almost as soon as the first. 8,000 CPU-seconds of pack building become about 4. An entry can never be stale, because the request names exact commits; entries are evicted by space and age.
    5. Cache servers stream the pack to the remaining runners, spreading the bytes over the cache fleet instead of the three replicas. A hot entry is copied to several cache servers, so no single NIC has to carry 13 GB/s. The replicas spent one build and keep serving their other repositories at normal latency.
    6. Runners that kept a workspace fetch only the new commits, and those requests collapse into a few dozen cache keys. A persistent runner has some recent main as its have, and there are only a handful of distinct recent mains, so hundreds of runners share each key. For monorepos we document blobless clones with sparse checkout as the CI default, because the cheapest pack is the one nobody asks for.
  4. A file server dies mid-push. The failure path. A server crashes after the pack is streamed but before the vote; the push still commits on the other two replicas, reads move away from the dead server, and its 800 k replicas are rebuilt elsewhere within the hour.
    1. Replica fs-0207 stops answering while a push to acme/api is between streaming and prepare. The router waits a bounded time for votes, 500 ms within a region, rather than for the slowest replica. fs-0412 and fs-1093 vote the same hash, which is a quorum.
    2. The router commits on the two healthy replicas, marks fs-0207 out of sync for this repository, and acknowledges the push. The client sees an ordinary success. The out-of-sync mark is recorded before the ack, so no reader can be routed to fs-0207 and see the old main, even if it comes back a second later. With two in-sync replicas left, the next push still reaches a quorum.
    3. Missed heartbeats declare the whole server dead, and every network with a replica on it drops to two. About 30 s of missed heartbeats, so a long pause does not trigger 20 TB of copying. Around 800 k replicas are affected; reads move to the other two replicas and writes still reach a quorum, so no repository becomes unavailable.
    4. The router queues the affected networks for repair, most urgent first, and picks a new home for each. First any network now down to one in-sync replica, which cannot take writes; then recently active networks; then the long tail. Destinations are chosen by free disk, CPU and rack, never a rack that already holds a replica of the network.
    5. Each new host copies its networks from a healthy replica with git, hundreds of copies in parallel across the fleet. 20 TB spread over ~300 destination hosts is ~70 GB each; at a throttled 100 MB/s that is about 12 minutes, and the throttle exists so repair never hurts live traffic. From the start the new replica receives every push as a non-voting member, so when the copy finishes it is at most one catch-up fetch behind.
    6. The router compares the new replica’s checksum with the healthy ones and admits it as a voting, readable replica. Equal checksums over every (ref, value) pair mean identical logical contents, whatever the pack layout. If fs-0207 comes back later, its replicas are stale copies of networks that already have three members, and they are deleted rather than trusted.
  5. A pull request from a fork, merged and indexed. The background path. Pull request metadata is rows in MySQL, but every diff and merge is computed by git on the file servers, mergeability is recomputed in the background whenever either side moves, and code search catches up from the push stream.
    1. A contributor opens a pull request from a branch in their fork into the parent’s main. The fork and the parent belong to one network with one object store, so the fork’s commits are already on the parent’s file servers and nothing is copied. The web tier writes the PR row and creates refs/pull/42/head in the parent pointing at the fork’s commit, which is how anyone can fetch a pull request.
    2. A PR event goes onto the bus, and a PR worker asks a replica to build the test merge. git merge-tree --write-tree runs merge-ort entirely in memory with no working copy, and writes the merge commit as refs/pull/42/merge. GitHub has written that moving to merge-ort turned merges that took 5 s or more into ones under 200 ms, which is what makes building them speculatively affordable.
    3. The worker records the result, keyed by the base and head commits it was computed for. Storing (base_sha, head_sha) with the result means a result can never be applied to the wrong pair: if either side has moved, the page shows “checking” instead of a stale answer.
    4. A reviewer opens the diff, which the web tier computes from the merge base on the file servers. The list of changed paths comes from comparing two trees, which is cheap; each file’s diff is rendered when it scrolls into view, with size limits. Rendered diffs are cached by (base blob, head blob, options), which never goes stale.
    5. The reviewer approves and presses merge; main moves to the prepared merge commit through the same replicated ref transaction as a push. The transaction’s old value is the main the test merge was built on, so if main moved a moment ago the update fails on every replica and the PR is re-checked instead. Branch protection (approvals, required checks on the head commit) is evaluated in the prepare phase.
    6. The merge’s ref update goes onto the bus like any push: webhooks fire, and every other open PR against main becomes stale. One row records main’s new commit. Every PR’s stored result names the base it was computed against, so all 5,000 open PRs on a monorepo become stale without 5,000 writes, and workers recompute the ones people are looking at first.
    7. The code search indexer reads the push event, finds the changed blobs and indexes only the ones it has never seen. Only the default branch is indexed. A blob that is already indexed, because a fork or another repository has the same file, just gains a repository in its list. GitHub has described its Blackbird index this way, sharded by blob id, at a scale of 45 M repositories, 115 TB of content and 15.5 B documents (2023).
    8. A developer searches code; the query carries the repositories they can read and is filtered inside the index. For most users that is “public, plus a few hundred private repositories”, passed as a filter evaluated during the search, not after it. People with access to tens of thousands of repositories are filtered by organisation instead, and the final page of hits is re-checked against permissions so a just-revoked access never shows a result.

Deep dives

Repositories as git on local disk

Why store repositories as real git repositories on file servers? Why not put the objects in a database or an object store and keep the servers stateless?

A git repository is a content-addressed object store (blobs for file contents, trees for directories, commits pointing at a tree and their parents, annotated tags) plus refs, the only mutable state, mapping names like refs/heads/main to commit ids. On disk, objects sit in packfiles where most are stored as deltas against similar objects, with an index for lookup and optionally a reachability bitmap. Serving a fetch means walking the commit graph, deciding which of millions of objects the client lacks, and streaming them as a new pack: thousands to millions of small random reads per operation, at 100 k operations a second at peak.

Git itself is the only fully compatible implementation of all of this: the protocol, packing, delta selection, merges, fsck and every edge case. Put anything other than a local filesystem under it and you either reimplement git against a new storage API or pay a network round trip for each of those small reads. Meanwhile 400 M repositories are mostly tiny and idle while a few are 100 GB and hot, so the unit of placement must cope with a 10⁵ spread in size.

  • Bare git repositories on local NVMe, placed by fork network, three replicas chosen
  • Objects in an object store or distributed KV, refs in a consistent database situational: you are willing to run a non-standard git server implementation over storage with fast random reads; Google has described serving its git hosting from JGit on its own distributed storage
  • A shared network filesystem (NFS) behind stateless git servers rejected
  • Git objects as rows in a relational database rejected

The answer: Store every repository as a bare git repository on local NVMe and let git do what git does. Place by fork network (a parent and all its forks share one object store, so they live together), keep three replicas of each network on servers in different racks, and put a router in front that maps repository → network → hosts and tracks which replicas are in sync. With ~30 TB servers kept ~70 % full, that is about 1,500 servers for 30 PB, each holding ~800 k replicas. Accept the cost of placement: the router rebalances networks by disk and by CPU, copying with git itself and switching the route only when the new copy’s checksum matches. This is the shape GitHub has described for Spokes (first called DGit): three replicas of each repository on ordinary file servers, replicated at the git level rather than the disk level.

One network grows to 100 GB and starts eating a third of its servers’ CPU. What do you do?

Move it, then make it cheaper. The router places big or hot networks on servers with headroom, dedicated ones for the very largest, so the neighbours stop paying for them. Then cut the cost per operation: frequent geometric repacks with multi-pack bitmaps, a pinned pack cache, a CDN bundle, and steering its CI toward blobless clones with sparse checkout. Splitting one repository across servers is the one thing we refuse to do, because git operations need the whole object graph in one place.

Why place by fork network rather than by repository?

Because forks share objects with their parent through alternates, which only works on the same filesystem. Placing by repository would mean copying the parent’s objects into every fork, the storage cost sharing exists to avoid, or reading objects remotely, the latency problem local disks exist to avoid. The price is that a network with 50,000 forks is one placement unit, which is the forks deep dive.

How do you move a network between servers without downtime?

Add the destination as a fourth, non-voting replica: it copies everything from a healthy replica with git and receives every new push while it catches up. When its checksum matches the voting replicas, the router swaps it in and drops the old one in a single routing change. Readers and pushers never notice. A 20 GB network at a throttled 100 MB/s moves in a few minutes.

Do the file servers use RAID?

No. The three replicas on three servers already are the redundancy, so RAID would double disk cost for protection we have, and a failed disk is handled like a failed server: its replicas are rebuilt elsewhere by many servers in parallel. GitHub’s earlier design used RAID plus a DRBD-mirrored hot spare for each file server, four copies of every disk; moving to application-level replicas let any server help repair any other and put the spares’ CPU to work.

How would you know placement is healthy?

Per server: disk fill, CPU spent in pack-objects, and the number of networks below three in-sync replicas. Per repository: CPU seconds and bytes served per minute, so the top hundred consumers are visible and can be moved before they hurt neighbours. A background audit that compares replica checksums daily tells us whether replication is actually keeping replicas identical, rather than just believing it.

Replicating a push: three replicas and a vote

A push has to land on three servers. Why not write it to a primary and replicate asynchronously, like a database? And what exactly do the replicas agree on?

With asynchronous replication a push is acknowledged by one server and copied later. If that server dies in between, the push is gone; worse, the surviving replica now shows main at an older commit than one a CI system already built and deployed. A ref that goes backwards breaks everything keyed on it. Disk-level mirroring avoids the loss but pairs servers into active and standby, idles the standby’s CPU and replicates every byte of repack churn.

Git gives a better split. Objects are immutable and named by their hash, so writing them is idempotent and safe on any replica at any time; an object that ends up unreferenced is harmless garbage. The only thing that can conflict is a ref update, and git already expresses it as a compare-and-swap: move refs/heads/main from old to new. So replication reduces to two parts: get the objects onto each replica, then have a majority agree on the ref transaction.

  • Stream to all three replicas, vote on the ref transaction, commit at two of three chosen
  • A primary with asynchronous replicas rejected
  • Disk-level mirroring (DRBD) to a standby server rejected
  • A consensus log (Raft) of ref updates per repository situational: you want leader-ordered writes and automatic membership changes and can run a replicated log per repository or per partition; GitLab has been working on Raft for Gitaly

The answer: The router coordinates each push in three phases. Stream: the client’s pack goes to all three replicas, each indexing and verifying it into a quarantine directory. Prepare: each replica takes the ref locks, checks that every ref still has the old value the client expected, and votes with a hash of the exact update; this is git’s reference transaction (update-ref --stdin), with the reference-transaction hook reporting the prepared state. Commit: with two identical votes the router tells the replicas to commit, which publishes the objects and moves the refs, and any replica that voted differently, timed out or failed to commit is marked out of sync for that repository before the client is acknowledged. Each replica keeps a checksum over all its (ref, value) pairs, updated by XOR on every change, so “in sync” is one comparison. GitHub has described Spokes as using a three-phase commit and this kind of checksum, costing four round trips to the replicas per push, and GitLab’s Gitaly Cluster votes on a hash from the reference-transaction hook in a similar way.

The router crashes after two replicas voted yes but before it sent commit. What happens?

The replicas hold prepared transactions with ref locks, and they abort them after a few seconds without a decision. No client was acknowledged, so aborting breaks no promise; the client’s connection dropped and it retries the push, which finds the same old value and succeeds. If commit had reached one replica before the crash, that replica now disagrees with the other two; its checksum gives it away, and repair resets its refs to the majority’s.

Two people push to main at the same moment.

Both read main at the same old commit and both send “old → new”. The router serialises ref transactions per repository, which costs nothing because pushes to one repository are rare, so the second prepare happens after the first commits and fails its old-value check identically on every replica. The second pusher gets git’s usual “rejected, fetch first” and rebases. Without that serialisation the two could each win a different replica and leave no majority.

Why not keep one replica in another region for disaster recovery?

You can, and GitHub described doing exactly that in “Stretching Spokes”. The cost is that a push waits for round trips to the distant replica whenever it is part of the quorum. With two replicas in one region and one far away, the near pair usually forms the quorum, pushes stay fast, and the far one lags by milliseconds; losing the whole region then means the far replica may lack the last pushes, a trade to choose explicitly.

How do you know replicas are really identical, not just their refs?

Refs plus content addressing are the whole logical state: if two replicas have the same refs, they hold the same reachable objects, or one of them is missing objects, which a connectivity check finds. So the checksum over refs proves logical equality, and a background job verifies object hashes and connectivity to catch disk corruption. Packs can differ freely between replicas; their layout is not part of the state.

What does the vote cost a push, and is that acceptable?

Within a region, four round trips of well under a millisecond each, plus the slower of two replicas’ index-pack. For a typical push that is tens of milliseconds next to the client’s upload. For a 1 GB push it is dominated by index-pack, which runs on the replicas in parallel. Pushes are 1 % of operations, so spending latency on them to make every read trustworthy is the right trade.

Making clones and fetches cheap

Every fetch makes the server compute a custom pack. How do you serve 100,000 of them a second without a fleet ten times the size?

A fetch has three costs. The ref advertisement: in the original protocol the server lists every ref before the client speaks, and a repository with 500,000 refs (every pull request has refs/pull/N/head and /merge) sends tens of MB on each no-op fetch. Counting: deciding which objects the client lacks by walking every commit and tree reachable from the wants and not from the haves, millions of objects in a big repository. Compressing: finding deltas and compressing objects into the pack.

Two observations make it cheap. Much of the answer can be precomputed: which objects an important commit can reach never changes, so it can be stored once as a bitmap. And most objects already sit in the repository’s packs as good deltas; if the client will also receive the base, the stored bytes can be sent as they are. A fetch then costs bitwise operations and a copy instead of a graph walk and recompression.

  • Reachability bitmaps, verbatim delta reuse, protocol v2, continuous repacks chosen
  • Shallow or partial clones for every client by default situational: for CI on very large repositories: shallow for one-off builds, blobless with sparse checkout for monorepos; documented and encouraged, not forced
  • One precomputed full pack per repository, sent to everyone situational: for full clones of hot repositories, as a bundle on a CDN followed by a normal incremental fetch
  • Generate every pack from scratch rejected

The answer: Keep every active repository well packed and bitmapped, and let git’s reuse do the work. File servers run maintenance continuously, triggered by how many new packs a repository has accumulated rather than by the calendar: geometric repacks that fold small packs into larger ones so the pack count stays logarithmic, multi-pack indexes, and bitmaps for the tips of important refs. Serve protocol v2 so clients request only the refs they need with ls-refs, which keeps the 500,000 refs/pull/* entries out of every ordinary fetch. Reuse stored deltas verbatim whenever the base is also being sent, and use delta islands so a fork is never served a delta against an object only another fork has. A typical fetch then costs ~20 ms of CPU and a typical clone ~2 CPU-seconds: ~8 k cores at peak. Bitmaps came from JGit at Google and were brought to git by GitHub engineers, who reported the kernel clone dropping from 44 s to 1.6 s.

A repository has had 10,000 small pushes since its last repack. What does a fetch cost now?

More, in two ways: thousands of small packs mean every object lookup checks many indexes, and recent objects are not in any bitmap, so counting walks them the slow way. That is why repacks are triggered by accumulated packs: a busy repository is consolidated every few hours by a geometric repack that only rewrites the small packs. A multi-pack index makes lookups one index again without rewriting anything big.

Why is a shallow fetch expensive for the server when it sends less data?

Because the server cannot use history to decide what to send. A depth-1 clone has no haves, so the server walks the whole tree of that commit, without a bitmap for that exact set and often without delta reuse, since bases may sit outside the cut. Fetching onto or deepening a shallow clone is worse, because the server must reason about a boundary it cannot see. In 2016 GitHub rate-limited the CocoaPods Specs repository, which was fetched about 1.1 million times a week and kept more than five server CPUs pegged; besides the volume, GitHub pointed at shallow clones and one enormous flat directory.

How does protocol v2 save work if the client still downloads a pack?

It removes the fixed cost every request used to pay. A CI poller or an IDE fetching every few minutes usually gets no new objects at all, and in the original protocol each of those requests still received the full ref advertisement, which was the whole response. With ls-refs and a prefix, a no-op fetch is one small request and a few hundred bytes back, and the server reads a handful of refs instead of half a million.

Why not cache every generated pack?

Because most fetches are unique: each client has its own haves, so the hit rate on arbitrary fetches is low and the space would be wasted. Caching pays where requests are identical, which is the CI herd and shallow clones of a just-pushed commit; those get the pack cache. Everything else relies on bitmaps and reuse making computation cheap enough to do every time.

How do you measure whether this is working?

CPU seconds per operation per repository, split into counting and compressing, tracked at p99 by repository size. The reuse ratio, bytes sent verbatim over bytes sent. Bitmap coverage, the share of sent objects that came from bitmapped packs. When counting time grows for a repository, its maintenance is falling behind, and that is an alert long before it is an outage.

The CI herd and the one hot repository

One push starts 2,000 CI jobs that all fetch in the same minute, and one repository is cloned a million times a day. Its three file servers cannot carry either. What do you do?

A repository’s capacity is its three replicas: about 96 cores and 75 Gbit/s between them, shared with ~2.4 M other replicas. The CI herd after a push to a busy monorepo is 2,000 shallow clones of a 400 MB pack in 60 s: ~130 cores and 13 GB/s. The popular repository is 1 M full clones a day of a 250 MB pack: ~70 Gbit/s at peak. Extra replicas help only linearly, and every extra voting replica slows pushes.

The two loads differ in a way that matters. The herd is a burst of byte-identical requests for a commit that did not exist a minute ago: ideal for coalescing, useless for any snapshot made in advance. The popular repository is a steady stream of full clones from everywhere: spread out in time so coalescing barely helps, but ideal for a snapshot that changes slowly and can live on a CDN.

  • A single-flight pack cache for identical requests, plus bundles on a CDN for hot repositories chosen
  • More replicas for hot repositories situational: as non-voting read replicas for a handful of very hot networks, on top of caching
  • Rate-limit the repository situational: as the last line of defence, per repository and per client, so one repository can never starve its neighbours
  • Ask the heavy users to change rejected

The answer: Put a pack cache between the router and the file servers. The key hashes (repository id, wants, haves, depth or filter, and the capabilities that change the bytes); the first request takes a short build lease and runs pack-objects on one in-sync replica, teeing the output into the cache, and concurrent requests for the same key stream the file as it grows. Hot entries are copied to several cache servers so the bytes spread across NICs; entries are evicted by space and age and never need invalidating, because commits are immutable. For repositories with sustained full clones, publish a bundle (a pack of the default branch plus its refs) to object storage behind a CDN every few hours and advertise it with git’s bundle URI feature: the client downloads the bundle from the CDN, then fetches only what changed since, a few MB from us. Behind both, the frontends cap concurrent pack builds per repository, so the worst case is a queue rather than an outage. GitLab documents the same idea for its own CI: Gitaly’s pack-objects cache deduplicates concurrent identical fetches and serves them from cache.

Could an attacker use the pack cache to read a private repository?

Only if an entry could be served for a different repository, and it cannot: the repository id is part of the key, and permission is checked on every request before the cache is consulted, exactly as for an uncached fetch. Two forks in one network asking for the same commit get separate entries. That costs some hit rate, and it is the right trade.

The CI system retries every failed fetch immediately. What happens when the cache tier is down?

Without limits, a retry storm: 2,000 requests fail, retry at once, and keep three servers pegged indefinitely. So the frontends cap concurrent pack builds per repository per replica, say 16, and reject the rest with a retryable error and a jittered Retry-After. The herd becomes a queue that drains in a few minutes, and the repository’s neighbours keep their latency.

How fresh is a bundle, and what if the client’s git is too old for bundle URIs?

It is rebuilt every few hours, or after enough new objects, so the follow-up fetch stays small; staleness only makes that fetch a bit bigger, never wrong, because the fetch brings the client to the real refs. Older clients do a normal clone, served by bitmaps and the pack cache. As clients upgrade, the share of full clones we build falls.

Ten times the CI volume. What breaks next?

Bytes before CPU: the cache already collapses the CPU, but 20,000 runners downloading 400 MB each is 8 TB per push. The real fix is fewer bytes per job: persistent runners with warm workspaces fetching a few MB, blobless clones with sparse checkout for monorepos, or the CI system caching the checkout itself. We make those the documented default for big repositories and size the cache fleet for the rest.

How do you find hot repositories before they hurt anyone?

Every file server reports CPU seconds and bytes served per repository each minute, and the router keeps a fleet-wide top list. A repository over a threshold, say 5 % of its servers’ CPU for ten minutes, gets a published bundle, cache priority and, if needed, a non-voting read replica. The same data shows which projects have pathological fetch patterns, which is sometimes a conversation with their maintainers.

Forks, alternates and the network repository

A fork must be instant even for a 5 GB repository. How do forks share objects, and what does that sharing cost you later?

A popular repository can have tens of thousands of forks, and most of them never change anything. Copying the parent’s objects into each would multiply storage by the fork count and make forking a large repository take minutes. Yet a fork is a separate repository with its own refs, permissions and possibly private contents, and contributors open pull requests from forks, which needs the fork’s commits next to the parent’s.

Git already supports sharing: a repository’s objects/info/alternates file names other object directories to read from. So the parent and every fork can point at one shared object store for the whole fork network, and each repository keeps only its own refs. The questions are what that couples together, and how to keep it private and maintainable.

  • One shared object store per fork network; members read it through alternates chosen
  • A full copy per fork rejected
  • Copy-on-write filesystem clones (reflinks, snapshots) situational: for one-off copies such as detaching a repository from its network, where a fast, independent copy is the goal
  • One global content-addressed store deduplicated across all repositories rejected

The answer: Store each fork network as one shared object store, with the parent and every fork as small repositories that hold only refs and read objects through alternates. Pushes to any member write their objects into the shared store, and the network’s repacks consider the refs of every member, so an object lives as long as any member can reach it. Use delta islands, which GitHub contributed to git, so objects reachable from one fork are not stored as deltas against objects only another fork has; otherwise serving that fork would force the server to recompute deltas. Check read permission per repository, and decide explicitly whether a commit id from one member may be fetched through another: GitHub documents that commits in a fork network can be reached through any repository in it, which is convenient and, as researchers showed in 2024, a leak for anything pushed by mistake. When a repository must leave its network (it goes private, or is detached), copy the objects it can reach into a new network; that is slow but rare, and it is the price of instant forks.

Someone pushes an API key to their fork, then deletes the fork. Is the key gone?

Not necessarily. The objects live in the network’s shared store and, in GitHub’s documented design, stay reachable by commit id through other members until garbage collection removes them, which only happens once nothing references them. Truffle Security showed in 2024 that commits from deleted forks could still be fetched through the parent this way, and GitHub said it works as intended. The honest answer is that a pushed secret must be rotated; the platform can also scan pushes for secrets and offer a purge that removes the objects from the whole network.

The parent repository is deleted. What happens to its 20,000 forks?

They keep working, because the objects belong to the network’s store, not to the parent. The network picks a new root, usually the oldest or most active fork, and the parent’s refs are removed. Objects that only the parent’s refs reached become unreachable and are collected later.

A network has 50,000 forks and full garbage collection takes hours. How do you keep it manageable?

Never run a full GC in the hot path. Geometric repacks fold new objects in cheaply, and full reachability over every member’s refs runs rarely as a throttled background job. Unreachable objects are kept in cruft packs for a grace period rather than deleted at once, because a concurrent push may be about to reference them. And the biggest networks get dedicated servers, because they are a placement unit we cannot split.

A public repository with public forks is made private. Now what?

It cannot stay in a network whose objects public forks can fetch, so the network is split. The public forks stay in the existing network with a new root, and the newly private repository gets a fresh network holding a copy of the objects its refs reach. Copying 5 GB takes minutes, during which the repository is read-only; the split is rare enough that being slow is fine.

Why does a pull request from a fork not need any objects copied?

Because the fork’s commits are already in the network’s shared store, which the parent reads through its alternates. Opening the pull request writes refs/pull/42/head in the parent pointing at the fork’s commit: one ref update and nothing else. Without the shared network, every pull request from a fork would begin with a fetch from one repository into another.

Pull requests: git computes, MySQL remembers

Where do a pull request’s diff and merge come from, and how do you show “no conflicts” on 5,000 open pull requests when main moves every two minutes?

A pull request is two kinds of data. Metadata: title, author, state, reviewers, comments, review state and required checks, which belong in a transactional database with indexes for queries like “my open pull requests”. Git data: the diff from the merge base and whether the head merges cleanly into the base, which only the commit graph and the file contents on the file servers can answer. Copying diffs into the database, or cloning repositories onto application servers, creates a second copy of git data that goes stale.

Mergeability is the expensive part. It depends on both base and head, so a single push to main changes the answer for every open pull request targeting main. A monorepo with 5,000 open pull requests and a push to main every two minutes would need 2,500 test merges a minute to keep every badge exact, almost all for pull requests nobody is looking at.

  • Metadata in MySQL; diffs and merges by git on the file servers; mergeability recomputed lazily in the background chosen
  • Recompute every open pull request on every push to the base rejected
  • Store review data inside git, as Gerrit’s NoteDb does situational: a review tool that wants review history versioned and replicated with the code, as Gerrit does
  • Precompute and store every diff in the database rejected

The answer: Keep pull request rows in MySQL, sharded by repository id so a repository’s pull requests, reviews and comments live together, recording the base and head commit at every update. Compute everything else with git on the network’s file servers: the diff from the merge base, and the test merge built with git merge-tree (merge-ort, no working copy) and stored as refs/pull/N/merge. Store the mergeability result with the (base, head) pair it was computed for. A push to main updates one row with main’s new commit, which makes every pull request against it stale without touching them; PR workers then recompute in priority order (being viewed, auto-merge enabled, recently updated, then the rest when idle), debounced so a burst of pushes costs one merge per pull request. Pressing merge moves main to the prepared merge commit through the same replicated ref transaction as a push, with main’s tested commit as the expected old value, so a merge can never land on top of a base it was not tested against. GitHub has written that it builds merges in the background ahead of the button, and that switching to merge-ort cut complex merges from 5 s or more to under 200 ms.

Someone presses merge just as main moves. What happens?

The merge is a ref transaction whose expected old value is the main the test merge was built on. Main has moved, so the old-value check fails on every replica, nothing is written, and the pull request is re-checked against the new main, which takes a fraction of a second with merge-ort. The user sees a brief “checking”, or a conflict if there now is one. A merge never tested against the current main cannot land.

A pull request changes 40,000 files. How do you render the diff?

Compute the list of changed paths first, which is cheap from two trees, then render each file’s diff on demand as it scrolls into view, with limits on lines per file and on total size before a “load diff” button. File diffs are cached by (base blob, head blob, options), which never goes stale. The page asks git for what is on screen, not for everything.

How do CI results attach to a pull request?

Statuses and check runs are rows keyed by commit id, not by pull request, because one commit can be in several branches and pull requests. The page looks up the checks for its head commit, and branch protection evaluates them when the merge transaction is prepared. A new push changes the head commit, so old results simply stop applying.

Why shard pull request tables by repository rather than by user?

Because nearly every query is within one repository: list open pull requests, load one with its reviews and comments, count them. Cross-repository views such as “awaiting my review” are served from a search index fed by pull request events, not by scattering a query over every shard. GitHub has written about splitting its MySQL into clusters by feature domain as it grew, and about the work of moving tables between them.

How stale can a mergeability badge be, and how do you know?

Measure the time from a base or head change to a fresh result, per priority class: a few seconds for pull requests being viewed or set to auto-merge, minutes for idle ones. A stale badge shows “checking” rather than a wrong answer, so staleness costs a short wait, never a wrong merge. Queue depth and the age of the oldest stale viewed pull request are the alerts.

The theory behind it

  • Replication and consistency: Leader-follower, multi-leader and leaderless replication; synchronous vs asynchronous; quorums; CAP and PACELC; consistency models from eventual to linearizable; read-your-writes and failover.
  • The thundering herd problem: When many clients act at the same moment and overwhelm a system: cache stampedes, retry storms, reconnect storms after outages, synchronised cron jobs and notification opens, cold starts after deploys, and the fixes: jitter, request coalescing, early refresh, backoff, admission control and staggering.
  • Merkle trees: How hash trees let systems compare and verify large datasets cheaply: structure and root hashes, finding differences between replicas, proofs of inclusion, uses in databases' anti-entropy, file sync, Git, certificate transparency, blockchains and audit logs, and design choices for building them.

Related

Report an issue

Something wrong with this question? A number that does not add up, a step whose answer key looks off, a typo. It goes straight to the author with the page you are on.

Watch a strong candidate answer thisTurn by turn, with the design drawn as they talkWalk through →Mock interview