Skip to content
← back to profile

Cursor: Git at any scale

Why Git repositories resist being distributed, what GitHub's Spokes did about it, and how a write-ahead log in an object store replaces three-phase commit.

dissecting
Git at any scale
Cursor · 17 August 2026

A rare thing: a storage design explained by someone who has maintained Git internals, with throughput numbers attached and the previous generation's architecture described fairly rather than as a foil.

01

Why a Git repository resists being distributed

intermediate

Start with what a repository actually is on disk, because every difficulty later follows from it. Git stores content as objects addressed by the hash of their contents: a blob for a file's bytes, a tree for a directory listing, a commit pointing at a tree and its parents.

Written one file per object that would be unusable at any real size, so Git periodically gathers them into a packfile, a single large binary file holding many objects, delta-compressed against one another, with a separate index file mapping each object's hash to its offset.

That format is very good at what it was designed for and hostile to everything else. Reading one object means consulting the index, seeking to an offset, and then walking a chain of deltas to reconstruct the content, each step landing somewhere unpredictable in a file that may be several gigabytes. It is random access across a large binary blob, repeated thousands of times to serve one clone, and the code doing it assumes a local filesystem because that is what it was written against.

So the obvious move, putting the repository on network storage and letting any server read it, performs terribly. Every one of those small random reads becomes a network round trip, and there are an enormous number of them. This is the constraint the whole article is about: Git's storage format cannot simply be moved somewhere shared, so hosting it at scale means deciding what to distribute instead.

There is a second property that matters as much and is easier to miss. A push is not an append. It is a negotiation, in which the client and server work out which objects the server lacks, followed by a packfile of exactly those objects, followed by an update to a reference such as a branch tip. The reference update is the part that must be atomic and ordered, because two people pushing to one branch is a conflict that has to be resolved by somebody, and the data itself is content-addressed and therefore harmless to have twice.

Holding those two facts together explains the shape of every solution in this space. The bytes are immutable and addressed by their content, so they can be copied freely and never conflict. The references are small, mutable and contended, so they need consensus. Any hosting architecture is a choice about where each of those two halves lives, and most of the differences between designs are about the second half rather than the first.

why this choice

The temptation is to treat this as a storage problem and reach for a network filesystem, and the packfile format is the reason that fails: it was designed for random access on a local disk, and every random read becomes a round trip. Recognising that the immutable content and the mutable references have opposite requirements is what makes the rest of the design legible, because the content can be replicated carelessly and the references cannot be replicated at all without an ordering.

in practice

This is the same split that appears in any content-addressed system. A container registry separates immutable layers, which can be cached anywhere and deduplicated globally, from mutable tags, which need an authoritative answer. So does a package registry, and so does the artefact store in a build system. The immutable half is a distribution problem and the mutable half is a consensus problem, and conflating them is how both end up done badly.

check yourself

Which half of a push genuinely needs an ordering, and why?

the bytesthe decisiongathered and compressedcontendedthe obvious movewhat actually performsgit pushnegotiate, then sendObjectsaddressed by contenthashReference updatebranch tip movesPackfiledelta chains, randomaccessNeeds an orderingtwo pushes, onebranchOn network storageevery read is a roundtripOn local NVMewhat Git was writtenfor
ClientData storeServiceExternaldashed = asynchronousconsidered, not chosen
Immutable content copies freely; a mutable reference needs an order
evidence · 2 sources
  1. 01
    Cursor, Git at any scale (2026)

    The framing quoted here, that packfiles are large binary files which must exist on a filesystem for Git to access them, and that their random access pattern is what makes networked storage unsuitable.

  2. 02
    Git internals: packfiles

    The object model and packfile format described here: blobs, trees and commits addressed by content hash, gathered into a pack with a separate offset index.

ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.
02

GitHub's answer, and the wall it reaches

advanced

GitHub's system, Spokes, has been the industry reference since around 2013, and the article is fair to it: its three central choices were correct for the problem as it stood. Replicate at the packfile level rather than trying to distribute Git itself, so ordinary Git tooling keeps working. Keep each replica as a real Git repository on local NVMe, so reads have the access pattern Git expects. And hold the replicas strongly consistent with three-phase commit, so a push is either everywhere or nowhere.

Three-phase commit is worth understanding rather than treating as a label, because its cost is the whole reason for the article. A coordinator asks every replica whether it can accept the push, waits for all of them to say yes, tells them to prepare, waits again, then tells them to commit. Every phase waits for every participant, which means the latency of a push is set by the slowest replica in the set, not the median and not the fastest.

That gives the architecture a property nobody wants: adding replicas makes writes worse. Three is the standard set, and three is plenty for a repository whose readers are humans. It is not plenty for a large enterprise monorepo whose readers are continuous integration jobs, where the read load is enormous and would happily be spread over dozens of machines. But going from three replicas to twelve means every push waits on twelve, so the thing that would fix reads directly damages writes. Read capacity and write throughput are coupled by the consistency protocol, and the coupling has the wrong sign.

There is a second cost that is operational rather than architectural, and the article's phrase for it is the memorable one: the repositories are pets rather than cattle. Because a replica holds state that exists nowhere else in a directly usable form, the system has to know exactly which machines hold which repository, keep that mapping in an external database, and continuously check that each replica is healthy and in agreement. Every one of those is a component that can be wrong, and a stale mapping is a repository nobody can find.

None of this is a design error. It is what happens when a set of correct decisions meets a load nobody was designing for, which in this case is two loads at once: monorepos whose CI fan-out wants many more readers than three, and a newer shape entirely, millions of small repositories created by agents, where the per-repository overhead of tracking and health-checking a pet dominates everything else about them.

why this choice

The lesson is not that three-phase commit is bad but that it couples two things a hosting system wants to scale independently. Because every phase waits for every replica, the replica count sets both the read capacity and the push latency, so there is no setting that serves a monorepo's CI fan-out and its developers at the same time. Any architecture with that coupling has a ceiling, and the ceiling arrives as a choice between slow pushes and insufficient read capacity.

in practice

The same coupling appears wherever strong replication is synchronous. A relational primary with synchronous replicas pays the slowest replica's latency on every commit, which is why the usual arrangement is one synchronous replica for durability and the rest asynchronous for reads. Consensus groups have it too, which is why systems that need both properties keep the consensus group small and put the bulk data on a separate replication path.

check yourself

Under three-phase commit, why does adding replicas to serve more CI readers hurt?

can you accept?prepare, commitprepare, commitsets the latencyadding replicas hurtsrepos are petsPushone branch updateCoordinatorthree-phase commitReplica 1real repo on NVMeReplica 2real repo on NVMeReplica 3the slow one todayCI wants 30 readersbut each one slowsthe pushPlacement databasewhich machine holdswhat
ClientServiceData storeExternaldashed = asynchronousconsidered, not chosen
Every phase waits for every replica, so more readers means slower writes
evidence · 2 sources
  1. 01
    Cursor, Git at any scale (2026)

    The three architectural choices attributed to Spokes, the observation that every step's latency is bound by the slowest server in the cluster, and the pets rather than cattle framing.

  2. 02
    GitHub Engineering, Stretching Spokes

    GitHub's own account of Spokes as a three-replica, strongly consistent repository replication system operating at the packfile level.

ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.
03

Continuity: a write-ahead log in a bucket

advanced

The replacement inverts what is authoritative. Instead of the repositories on disk being the truth and the object store being a backup, an append-only write-ahead log in S3-compatible object storage is the truth, and every repository on every disk is derived from it. Each entry in that log describes one accepted change, and the packfiles it refers to are stored as their own objects beside it, so the log stays small and the bulk data is separate.

That is a familiar move with an unfamiliar substrate. A database's write-ahead log records the intention before the change is applied, so recovery is a matter of replaying it; here the same idea is applied across machines rather than across a crash, so a machine that has fallen behind is not repaired, it simply replays. What makes it possible at all is that object storage acquired the one primitive the design needs.

That primitive is an atomic compare-and-swap.

S3 now supports conditional writes: put this object only if it does not already exist, or only if its current version matches the one I read. That turns appending to the log into a race that exactly one participant wins, which is the ordering the mutable half of Git needs. So the consensus that Spokes ran between its own replicas is delegated to the object store, which already provides it, rather than being implemented and operated.

The consistency claim that follows is unusually strong for something built this way, and the article states it plainly: no push is acknowledged until it has been fully persisted, all pushes are linearizable through those atomic operations, and every view of every repository is fully consistent. The order in that first clause is the important part. Persist, then acknowledge, which is the same discipline as a messaging server that stores a message before sending the tick, and for the same reason: the window between accepting and durably storing is the window in which a crash loses something the client believes is safe.

The price is a round trip to object storage in the write path, and it is worth being clear that this is a real cost rather than a free lunch. What it buys is that durability and ordering both come from a service whose availability and durability are somebody else's problem, at eleven nines, with no cluster of your own to keep in agreement. For a workload where pushes are frequent but not latency-critical in the way a database commit is, that is an excellent trade, and the throughput numbers later in the piece are what make the case.

why this choice

The interesting move is not using object storage for bulk data, which everyone does, but making it the authority for ordering. That is only possible because conditional writes give a compare-and-swap, and it is what removes the consensus protocol from the system entirely: the ordering exists, and no cluster of yours has to agree on it. Delegating the hard guarantee to a service that already provides it is usually cheaper than implementing it, and until conditional writes existed this particular delegation was not available.

in practice

The same conditional-write primitive is what the open table formats now build on. Iceberg and Delta both need exactly one writer to win when advancing a table's current snapshot pointer, and both moved to compare-and-swap on object storage rather than depending on an external catalogue to serialise it. The pattern is worth recognising: an append-only log plus one atomically swapped pointer is enough to give a shared mutable thing a total order.

check yourself

Which object storage capability makes the write-ahead log design possible?

content addressedthen claim the orderexactly one winnerread again, retrypersist, then acknowledgePushnot yet acknowledgedPackfile objectsimmutable, writtenfirstConditional writecompare and swap thelog headConcurrent pushloses the swap,retriesWrite-ahead logappend only,linearizableAcknowledgedonly after it ispersisted
ClientData storeServiceExternaldashed = asynchronousconsidered, not chosen
The log in the bucket is the truth, and one writer wins the swap
evidence · 2 sources
  1. 01
    Cursor, Git at any scale (2026)

    That Continuity persists a write-ahead log to S3-compatible storage, that pushes are linearizable through atomic compare-and-swap, and the stated guarantee that no push is acknowledged until fully persisted.

  2. 02
    AWS, Amazon S3 conditional writes

    That S3 supports conditional writes, which is the compare-and-swap primitive the design depends on for ordering.

ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.
04

The repository on disk is a cache

advanced

Once the log is authoritative, the repositories on disk change status entirely. They are no longer state that must be protected, tracked and health-checked; they are a warm cache, materialised from the log when a node needs one and discardable at any moment. The article's word for what that buys is stateless: nothing on a node is irreplaceable, so a node is interchangeable with any other node.

That is the change that turns pets into cattle, and the consequences are mostly about what stops being necessary. No external database recording which machine holds which repository, because any machine can hold any repository. No continuous validation that replicas agree, because agreement is checked on every read against the log rather than maintained between peers. No careful repair of a divergent replica, because a divergent replica is thrown away and rebuilt from the log, which is strictly cheaper than reconciling it.

Which node materialises which repository is then decided by rendezvous hashing rather than by a lookup. Every node can compute, from the repository's name and the current node list, which nodes should hold it: hash the name together with each node's name, sort the scores, take the top few. Because it is a computation rather than a stored mapping, there is nothing to keep current and nothing to be stale, and when the node list changes only the repositories whose top scores moved need to relocate. It is consistent hashing's cousin, and it handles weighting more cleanly.

The replica count then becomes a per-repository dial rather than an architectural constant, which is the payoff for all of this. A large monorepo whose CI generates enormous read load can be materialised on hundreds of nodes, because replicas no longer participate in a consensus round and therefore no longer slow pushes down. At the other end, a tiny repository created by an agent can live on exactly one node, because the object store is the availability guarantee: if that node goes away, another materialises the repository from the log.

It is worth noticing what the trade actually is. A read served by a node that does not yet hold the repository must wait for it to be materialised, which is a cold start, and the cost of that is proportional to the repository's size. The design does not eliminate that cost, it relocates it: cold starts happen on the read path instead of divergence happening on the write path, and a cold start is at least a failure mode that resolves itself.

why this choice

Making the on-disk repository a cache is what decouples the replica count from the write path, and that single decoupling is what the whole architecture was for. Spokes could not add readers without slowing writers because replicas were consensus participants; here they are caches, so the count can be one or hundreds according to the repository. Rendezvous hashing matters for the same reason the log does: a computed placement cannot go stale, so there is no mapping to maintain and nothing to be wrong.

in practice

The general form is worth recognising because it appears wherever a system stops treating local state as precious. A stateless application server materialises its cache on demand and is replaced rather than repaired. A CDN edge holds a copy that can be evicted at any moment because the origin is authoritative. In every case the enabling condition is the same: something else holds the truth, cheaply enough to rebuild from, which is exactly what a log in object storage is.

check yourself

How is a replica that has diverged from the log repaired?

name plus node listas many as reads needthe bucket is the backupmaterialisedthe relocated costWrite-ahead logthe only authorityRendezvous hashingcomputed, never staleMonorepohundreds of replicasfor CIAgent repositoryone replica is enoughWarm cache on diska normal GitrepositoryCold startmaterialise on firstread
Data storeServiceEdge / CDNExternaldashed = asynchronousconsidered, not chosen
Any node can hold any repository, because none of them holds the truth
evidence · 2 sources
  1. 01
    Cursor, Git at any scale (2026)

    That repositories on disk are treated as a warm cache materialised from the log, that rendezvous hashing decides node placement, and that replica counts range from one for tiny repositories to hundreds for monorepos.

  2. 02
    Thaler and Ravishankar, A name-based mapping scheme for rendezvous (1996)

    The rendezvous hashing algorithm itself: score each node against the key, take the highest, with no stored mapping and minimal disruption when the node list changes.

ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.
05

How a read proves it is current

intermediate

This is the part of the design that repays the most attention, because it is where the correctness actually comes from. When a replica is asked to serve a read, it does not assume that whatever it holds is up to date. It asks the object store, using a conditional GET with the ETag it already has for the log's head.

There are exactly two answers and both are cheap. A 304 Not Modified means nothing has changed since the version the replica holds, so it can serve immediately from its warm on-disk repository; the article reports that this metadata-only operation takes less than 10ms on average. A 200 means the log has advanced and comes with the latest index, so the replica catches up on the entries it is missing before answering, and is then current by construction.

So the guarantee is not that replication succeeded. It is that a stale replica cannot serve a stale answer, because it finds out that it is stale before it answers. That is a materially different property from the usual arrangement, where a replica serves whatever it has and the system's consistency is whatever the replication lag happens to be, and it is why the design can claim that every view of every repository is fully consistent while replicating optimistically.

The cost is one small round trip to the object store per read, and this is the trade to weigh rather than skim. Ten milliseconds is a great deal next to a local disk read and almost nothing next to cloning a repository, which is what makes it acceptable here: Git operations are large enough that a fixed 10ms verification disappears into them, while the same overhead on a per-key cache lookup would be absurd. The technique is not universal; it fits because of the size of the operations it protects.

It is also what makes the write path's promise mean something. Because pushes are linearizable through the log and every read verifies against the log, a client that has just been told its push succeeded will see that push from any replica it subsequently reads from, which is read-your-own-writes without pinning anybody to anything. The two halves of the design are one mechanism seen from two directions.

why this choice

Verifying on read rather than trusting replication is what buys consistency without a consensus protocol. The usual arrangement makes correctness depend on replication having worked, so the consistency of the system is whatever the lag is; here a replica discovers it is behind before it answers, so replication is free to be unreliable. The reason it is affordable is the ratio: a 10ms metadata check is invisible inside a Git operation and would be ruinous inside a cache lookup.

in practice

This is the same conditional request that HTTP caching has always used, applied to a different question. A browser sends If-None-Match and gets a 304 to avoid transferring a body it already has; here a replica sends the same thing to avoid serving content it should no longer serve. Worth remembering as a general technique: a cheap conditional check in front of an expensive operation converts a correctness assumption into a verified fact.

check yourself

A replica receives a read. What does it do before answering?

arrives anywhereconditional GETconditional GETnothing changedreplay the gapRead requestclone, fetch, browseReplicaholds an ETag for thelog head304 Not Modifiedunder 10ms on average200: new indexthe log has moved onServe from diskwarm cache, currentCatch up firstthen serve, current
ClientServiceData storeExternaldashed = asynchronous
Ask before answering, so a stale replica cannot serve a stale answer
evidence · 1 source
  1. 01
    Cursor, Git at any scale (2026)

    That replicas perform a conditional GET with an ETag, that a 304 is a metadata-only operation taking less than 10ms on average, and that a 200 returns the latest log index for catch-up before the read is served.

ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.
06

Replication that is allowed to fail

advanced

Given that every read verifies against the log, replication no longer has a correctness job. Its only job is to make the common case fast by getting updates to replicas before anybody asks for them, and something whose only job is to be an optimisation can be built very differently from something that has to be right.

So it is gossip over UDP. A node that accepts a push tells the others, best effort, in a datagram that may simply not arrive. There is no acknowledgement, no retransmission and no delivery guarantee, and the article is direct about why that is acceptable: a lost packet costs a replica one 200 instead of one 304 on its next read, which is to say it costs one catch-up that would have happened anyway. Nothing is incorrect, only slightly slower.

The sentence worth taking from this section is the design goal it states: always correct when degraded, and always fast when healthy. Those two properties usually pull against each other, and the reason they do not here is that they have been assigned to different mechanisms. Correctness lives entirely in the conditional read against the log. Speed lives entirely in the gossip. Neither mechanism is trying to provide both, so neither has to be compromised for the other.

That separation is the transferable idea, and it inverts the usual instinct. Most systems make replication reliable and then read from replicas hopefully; this one makes replication unreliable and reads carefully. The second arrangement is easier to build, because an unreliable broadcast has no failure modes worth the name, and it degrades better, because a replication outage produces slower reads rather than wrong ones. It is also what makes hundreds of replicas practical. Reliable delivery to hundreds of nodes is an increasingly expensive proposition, with acknowledgement tracking and retry state per peer, and it is exactly the cost that made adding replicas painful in the consensus design. Fire-and-forget datagrams cost the sender almost nothing per additional recipient, so the replica count stops being a factor in what replication costs.

why this choice

Assigning correctness and speed to different mechanisms is what lets each be built for one purpose. Because the conditional read makes staleness self-correcting, the replication layer can be a best-effort UDP broadcast with no acknowledgements, which has almost no failure modes and no per-peer cost. The usual arrangement, reliable replication plus hopeful reads, is harder to build and degrades worse, because a replication failure produces incorrect answers rather than slower ones.

in practice

The general principle is that a component's guarantees should be as weak as the system can tolerate, because weaker guarantees are cheaper and fail less interestingly. Cache invalidation messages are the everyday version: a lost invalidation is survivable if entries are versioned or expire, so the delivery mechanism can be cheap, whereas a design where correctness depends on the invalidation arriving needs that delivery to be reliable and will still be wrong occasionally.

check yourself

Which everyday mechanism has the same shape as this gossip layer?

best effortthe usual instinctusuallysometimesserve at oncecatch up, then servePush acceptedalready in the logGossip over UDPno acks, no retriesReliable deliveryper-peer state,costly at scalePacket arrivesreplica alreadycurrentPacket lostreplica does not knowyetNext read: 304fast when healthyNext read: 200correct when degraded
ClientQueue / streamServiceExternaldashed = asynchronousconsidered, not chosen
Correctness in one mechanism, speed in another, neither compromised
evidence · 1 source
  1. 01
    Cursor, Git at any scale (2026)

    That replication is optimistic, using gossip UDP packets around the cluster, that packet loss is acceptable because every read verifies consistency against the object store, and the stated goal of being always correct when degraded and always fast when healthy.

ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.
07

The numbers, and where the bottleneck moved

advanced

The measurements are what turn the argument into evidence, and there are three worth holding. On S3 Standard the system sustains up to 120 pushes per second while simultaneously compacting and replicating the compacted data to every other node. On S3 Express One Zone, the lower-latency variant, it exceeds 300 pushes per second. And stress testing showed linear scaling for reads up to 100 replicas with no regression in push throughput at all.

That third number is the claim the whole architecture was built to make. In the consensus design, replicas and push throughput were coupled by the protocol, so reads could only be scaled by making writes worse. Here they are independent, and the measurement says so: a hundredfold increase in read capacity for no measurable write cost. Everything else in the design is machinery in service of that one property.

The second number is interesting for a different reason: what it says about where the limit now sits. Moving to faster storage roughly tripled push throughput and then stopped being the constraint, because the system became bottlenecked by the speed at which Git can compact the on-disk data. The bottleneck moved out of the storage layer and into Git itself, which is the honest sign that the storage problem has been solved as far as this design can solve it. Optimising the object store further would now buy nothing.

Compaction is also where the design gets one more benefit that is easy to skip past. Only the primary compacts, and the result is applied to both its on-disk repository and the log at once, so every other replica downloads the already-compacted packs from the object store rather than repacking the same data itself. The article's phrase for this is trading bandwidth for CPU, and it removes a genuine hazard from the previous generation, where repacking a large repository across several nodes was an availability risk on all of them at the same time.

One last property is worth naming because it comes free and is the sort of thing you only appreciate after an incident. Every push is in the log, permanently, which means the system has a complete and authoritative record of what happened without any external database keeping it. The article notes this as what lets a Git bug be analysed and remediated retrospectively, and that is a genuine operational difference: the question of what state a repository was in last Tuesday is a lookup rather than an investigation.

why this choice

The number that matters is the one that shows the coupling gone: a hundred read replicas with no push throughput regression. That is what the log, the warm cache and the unreliable gossip were all for, and without the measurement it would only be an argument. The second lesson is where the bottleneck went: once faster storage stopped helping and Git's own compaction became the limit, the storage problem was as solved as this design can make it, and further work on the object store would be optimising something that is no longer the constraint.

in practice

Watching where a bottleneck moves is the honest way to know when to stop. The pattern to copy is the compaction one: do the expensive transformation once, centrally, publish the result, and let everyone else download it rather than recompute it. A build cache does this, a CDN doing image transformation at the edge does this, and a materialised view does this. In each case the alternative is every consumer spending CPU on identical work.

check yourself

What does keeping every push in the log give operationally?

storage was the limitnow it is notdone in one placebandwidth for CPUthe claim, measuredS3 Standardup to 120 pushes/sS3 Express One Zoneover 300 pushes/sGit compactionthe new bottleneckPrimary compactsapplied to disk andlog100 replicasdownload compactedpacksLinear read scalingno push regression
Data storeServiceEdge / CDNExternaldashed = asynchronous
Reads scale to a hundred replicas; the limit is now Git, not storage
evidence · 2 sources
  1. 01
    Cursor, Git at any scale (2026)

    The throughput figures quoted here: up to 120 pushes per second on S3 Standard while compacting and replicating, over 300 on S3 Express One Zone before becoming bottlenecked by Git's compaction speed, and consistent linear read scaling to 100 replicas with no push throughput regression.

  2. 02
    AWS, S3 Express One Zone

    That the storage class used for the higher figure is a single-zone, lower-latency variant, which is why the comparison isolates storage latency as the variable.

ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.