System Design Simple logoSystem Design Simple Follow on YouTube

System Design

How thousands of writers update one repository: objects first, refs last

Many writers can share one repository if they upload immutable data in parallel and publish through small compare-and-swap updates on branch pointers. With a lab.

How thousands of writers update one repository: objects first, refs last: diagrams from the video

Drafted with AI assistance, then checked and edited by a human engineer · How we write

Many writers can upload immutable Git data in parallel, then publish changes through small compare-and-swap transactions on branch pointers. Writers using separate branches avoid most pointer conflicts; writers sharing one branch still contend.

Prerequisites

Git stores history as objects (commits, trees, file contents) and names each branch with a ref that holds one commit ID. Optimistic concurrency skips locks and checks for a clash only at write time, the version check from two fans, one seat.

Definitions

Definition

Compare-and-swap (CAS): an update that carries the old value the writer expects. The store applies it only if the record still holds that value, and rejects it otherwise.

Definition

Pack: one file holding many Git objects, compressed together, plus an index for finding them.

Definition

Manifest: the list of packs that currently make up the repository. A pack absent from the manifest is invisible to readers.

Definition

Quorum: a majority of replicas. An entry recorded by a quorum survives the loss of any minority.

Definition

Freshness token: a position in the ref log. A reader holding the latest position knows its copy of the refs is current.

How does one repository take many writers?

A push carries two kinds of data. Git identifies an object by hashing its type, length and contents, so identical objects share an ID; hash collisions need separate handling. According to GitHub's write-up, pack uploads use distinct immutable storage keys, so writers upload independently. A ref such as refs/heads/main is a few dozen mutable bytes that every writer on that branch wants to change, which makes it the hot key.

Publication order. A writer uploads its pack first and publishes the ref last, so a committed ref never points at an unfinished upload. Connectivity checks and garbage collection must keep every object reachable from a published ref.

Conflict checking. The publish step is one small transaction, "move main from A to B". If main no longer equals A, the store rejects it; the writer reads the new value, rebases or merges, and retries. Git does this locally: git update-ref refs/heads/main NEW OLD refuses to change the ref unless it still holds OLD, and --stdin mode applies several such checks all or nothing (Git docs).

Durability. The ref store must survive machine loss without forgetting a published ref. In Raft, a leader commits a current-term entry after durable replication to a majority, and election and log rules preserve committed entries across leadership changes.

What GitHub's write-up reports

This section follows GitHub's November 5, 2026 post, Inside GitHub's new Git storage architecture; an earlier post covers why the change was needed.

PartWhat it holds or does
Azure Blob StoragePacks, indexes, bitmaps, written once to unique keys
Ref-CASReplicated log service holding refs and the pack manifest, no object data
Virtual replicasDisposable compute; local disk is a rebuildable cache
Compaction nodesMerge small packs in the background

On a push, a replica uploads the pack, then commits one Ref-CAS transaction that adds it to the manifest and moves the refs, checking each expected value and locking only those refs. A transaction commits once a majority of log replicas record it. The post calls the LSN of the latest commit the repository's version; this article calls it the freshness token. A read that already holds it gets a reply with no references.

The first prototype used a write-ahead log for reference state, indexed by one conditionally updated blob, and reached only the mid-hundreds of writes per second. Splitting the index raised write throughput, but every read then consulted every segment. In internal testing the new design handled 5,000 concurrent agents sending 7,200 pushes per second to one repository. Separately, it notes that agents mostly push to their own branches, so most updates do not contend. The write-up puts Ref-CAS in the tens of thousands of operations per second and says it is in limited private preview.

Worked example

Assume a 10 ms round trip. The serialized line counts index updates that each wait for the last; with batching one update can carry several pushes.

Separate refs, all parallel: read + CAS = 2 round trips      ≈ 20 ms total
Serialized index updates:    1 update per RTT = 1 / 0.010 s  = 100 updates/s
                             1,000 updates                   ≥ 10 s
100 writers on one ref, synchronized rounds:
  attempts   = 100 + 99 + ... + 1 = 100 × 101 / 2           = 5,050
  rejections = 5,050 - 100                                  = 4,950

Code: what contention costs

This in-process simulation gives the ref store an assumed 10 ms round trip. Each agent reads its ref, attempts a CAS, and retries on rejection. The script and both recorded runs are kept with our lab files (labs/thousand-agents-push-one-repo); run it with python3 -I ref_cas_lab.py.

# ref_cas_lab.py: many writers, one ref store, compare-and-swap
import asyncio, time

RTT = 0.010  # assumed 10 ms round trip to the ref store

class RefStore:
    def __init__(self, one_at_a_time=False):
        self.refs, self.ok, self.rejected = {}, 0, 0
        self.gate = asyncio.Lock() if one_at_a_time else None  # serialized model

    async def read(self, ref):
        await asyncio.sleep(RTT)
        return self.refs.get(ref)

    async def cas(self, ref, expected, new):
        if self.gate:
            async with self.gate:
                return await self._cas(ref, expected, new)
        return await self._cas(ref, expected, new)

    async def _cas(self, ref, expected, new):
        await asyncio.sleep(RTT / 2)          # request travels
        ok = self.refs.get(ref) == expected   # the compare
        if ok:
            self.refs[ref] = new              # the swap
            self.ok += 1
        else:
            self.rejected += 1
        await asyncio.sleep(RTT / 2)          # reply travels
        return ok

async def agent(store, i, ref):
    while True:                               # read, build on top, try to swap
        old = await store.read(ref)
        if await store.cas(ref, old, f"c{i}-on-{old}"):
            return

async def run(label, agents, same_ref, one_at_a_time=False):
    store = RefStore(one_at_a_time)
    t = time.perf_counter()
    await asyncio.gather(*(agent(store, i, "main" if same_ref else f"agent-{i}")
                           for i in range(agents)))
    secs = time.perf_counter() - t
    print(f"{label:<42} ok={store.ok} rejected={store.rejected} time={secs:.2f}s")

async def main():
    await run("1,000 agents, own refs, parallel", 1000, False)
    await run("1,000 agents, own refs, serialized", 1000, False, True)
    await run("100 agents, all on main, parallel", 100, True)

asyncio.run(main())

Two runs on 2026-11-06, Mac, Python 3.14, standard library only:

CaseRejectedRun 1Run 2
1,000 separate refs, parallel00.06 s0.42 s
1,000 separate refs, serialized011.63 s12.15 s
100 writers sharing main4,9502.28 s2.29 s

Counts matched in both runs; times depend on event-loop scheduling. The code has no round barrier; 4,950 rejections occurred in these runs because every remaining writer read after the same sleep. The triangular formula assumes synchronized rounds. These timings measure the simulation, not Git, storage or consensus.

Failure modes and edge cases

Lost majority. If the log cannot reach a quorum, it cannot commit, so pushes must fail or wait instead of publishing on a minority. Server failover, active-passive vs active-active covers the failover side, including fencing.

Ambiguous commit. After a timeout, the outcome is unknown. A fresh read showing the proposed value confirms the intended state. A different value cannot establish failure because another update may have followed. General services need a durable operation ID or transaction-status lookup before retrying an operation that must run once.

Orphaned uploads. A writer that uploads a pack and dies leaves a pack outside the manifest. Cleanup must coordinate with publication so a paused writer cannot publish a pack after deletion. One general design uses expiring upload leases, rejects publication after expiry, and retains retired packs while readers may still need them. GitHub's write-up leaves its detailed GC safeguards for another post.

Stale reads. A stale ref still names a valid older snapshot; reads that need the latest state use the freshness check, and stale likes and safe withdrawals covers when staleness is fine. Contention shows up first in rejected CAS calls per ref and p99 push-to-commit time.

Trade-offs

OptionGood forCosts youUse when
Lock, then writeSimple, no retriesThroughput capped by lock hold time; stuck locksFew writers
Conditional writes on one index blobNo extra serviceIf updates serialize, about 1 / RTT updates per secondLow write rates
CAS through a quorum logOrdered, durable, parallel across refsA consensus service to run; a quorum round trip per commitMany writers, mostly on different refs
Batch or merge queue on the hot refOne CAS for many changesAdded delay, per-change failure handlingMany writers on the same ref

My take: the split is old and well understood, and the hard part is operating it. You run a consensus log, keep pack counts flat with compaction, and delete data safely. A busy shared main still serializes and needs a merge queue or batching.

Common mistakes

Mistake: publishing the ref before the pack upload finishes. Instead: upload first, publish last, in one transaction with the manifest change.
Mistake: reading a different ref value after a timeout as proof the update failed. Instead: treat the outcome as unknown and check an operation ID or transaction status.
Mistake: deleting unlisted packs on age alone. Instead: make publication fail once an upload lease has expired, so cleanup and publishing cannot both win.
Mistake: putting every writer on one key. Instead: spread writers across keys, one branch per agent here and sharded counters in [a million likes at once](/million-likes-at-once/).

In an interview

Clarify how many writers share one record, assume a round trip, split payload store from pointer store, go deep on CAS and the quorum log, weigh the hot key, and close on the metrics you would watch.

A 60-second answer

Keep the shared record tiny, pointers and versions only, and write payloads first under unique immutable keys. Each change is a CAS through a replicated log that commits on a majority; losers re-read and retry. Spread writers across keys so most CAS calls never meet, and give a single hot key a merge queue. Readers compare a log position first, as in one database writes, ten read.

Practice

BeginnerWhy does a branch pointer conflict more than the files behind it?

Every writer on the branch changes the same few bytes, while each pack goes to its own immutable key.

IntermediateA writer publishes the ref before its pack upload finishes, and the upload then fails. What breaks?

The published ref points at missing objects, so fetches fail. Upload first, publish last.

IntermediateAt a 10 ms round trip, how many updates per second does a strictly serialized index allow?

About 1 / 0.010 = 100.

AdvancedFifty agents push to one branch. Each synchronized round, all remaining agents read the same version and exactly one succeeds and leaves. How many CAS attempts in total?

50 × 51 / 2 = 1,275, of which 1,225 are rejected. Fifty separate branches need 50.

AdvancedA push times out after sending its CAS. A re-read shows a value that is neither the old nor the proposed one. Did the push fail?

Unknown. Another update may have followed a successful one. Look up the operation ID or transaction status before retrying.

AdvancedHow do you stop cleanup from deleting a pack that a paused writer is about to publish?

Give uploads expiring leases, reject publication after expiry, and delete only expired, unlisted packs.

Cheat sheet

Term or formulaMeaning
CASWrite only if the value still equals the expected old value
Pack · manifestImmutable bundle of objects · list of packs readers can see
Quorum commitDurable once a majority of replicas record it
Freshness tokenLog position that tells a reader its refs are current
1 / RTTCeiling for strictly serialized updates
n(n+1)/2Attempts for n writers on one ref in synchronized rounds

Further reading

  • GitHub, Inside GitHub's new Git storage architecture: the original write-up with the Ref-CAS design, rejected prototypes and test numbers.
  • GitHub, Building Git infrastructure for agent-scale development: the earlier post on why the old design struggled with more writes.
  • Git, git-update-ref: CAS and all-or-nothing ref transactions you can run locally.
  • Microsoft Learn, Blob conditional headers: If-Match and ETag preconditions on one object.
  • Raft paper: leader election, log replication and the rules that keep committed entries.

This article explains general patterns for write contention on a shared record. Statements about GitHub's system are limited to what their public write-up states; the lab and arithmetic are our own illustrations, not measurements of any company's systems.

Frequently asked questions

What is compare-and-swap in Git?

An update that names the value you expect to replace. Git applies it only if the ref still holds that value, so two writers cannot silently overwrite each other. You can run it locally with git update-ref and an old value.

Why not keep branch pointers in object storage?

You can, using ETag preconditions. GitHub's first prototype used a write-ahead log for reference state, indexed by one conditionally updated blob; each index update waited on the last, so it stayed in the mid-hundreds of writes per second. Splitting the index helped writes but made every read check every segment.

Does this remove conflicts on main?

No. Pushes to the same ref still race and one wins each time. The design scales because most writers update different refs.

Sources

  1. GitHub blog: Inside GitHub's new Git storage architecture (Nov 5, 2026)github.blog
  2. GitHub blog: Building Git infrastructure for agent-scale development (Oct 2026)github.blog
  3. Git documentation: git-update-ref (old-value check and transactions)git-scm.com
  4. Microsoft Learn: Conditional headers for Blob service operations (If-Match, ETag)learn.microsoft.com
  5. Ongaro and Ousterhout: In Search of an Understandable Consensus Algorithm (Raft)raft.github.io

system designcompare-and-swapwrite contentionhot keyreplicated logquorum

System Design Simple logo
System Design Simple

Written by a software engineer and system architect with 10+ years of building production web, payment, ticketing and accounting systems in Japan and Nepal. About us