2026-09-25 · 9 min read

Reftable Is 10x Faster Until 100 Writers Show Up

Isometric diagram comparing many individually locked ref files to a single sorted table stack where many grey lines queue for one shared lock, with one green node marking the only successful write.

Here's the benchmark that sold reftable to a lot of teams: 10,000 refs written in 39–42 milliseconds instead of 436–650 milliseconds. At 50,000 refs, 199–213ms instead of 2.1–12.4 seconds. On disk, 1.4MB instead of 198MB. Every one of those numbers is real, reproducible, and — if you stop reading there — no help at all in deciding whether to migrate.

A ref, in case you spend your days one layer above Git, is a named pointer to a commit: your branches and tags. The classic files backend stores each one as its own tiny file under .git/refs/. The reftable backend stores the entire ref database as a small number of sorted, binary-searchable table files under .git/reftable/. Git 2.55 shipped it in finished form, and Git 3.0 will make it the default for new repositories.

The headline benchmark writes refs from one process, one at a time. That measures throughput for a single writer. It says nothing about what happens when many writers arrive together — which is exactly the shape of a real CI pipeline. That's the axis where reftable changes behaviour, and it's the reason this is a concurrency decision dressed up as a speed decision.

What actually changes under the hood

The files backend gives every ref its own lock. Two jobs updating refs/heads/feature-a and refs/heads/feature-b touch different files and never wait on each other. Contention scales away because the lock is per object.

Reftable concentrates state. A write appends to a shared stack described by a single tables.list file, and Git periodically folds small tables together in a compaction step (reftable.geometricFactor, default 2). That's what makes bulk writes fast: one sorted structure beats ten thousand loose files. It's also what makes concurrent writes queue, because compaction needs the same lock the writers need, and a burst of simultaneous writers all pile onto one file.

Nothing here is a bug. It's the structural consequence of the design that produces the impressive numbers. Which means you shouldn't be surprised by the failure mode — you should plan for it.

The honest scorecard

  • Bulk writes: 10–60x faster. At 10,000 refs, 436–650ms becomes 39–42ms. The files-backend number at 50,000 refs was also wildly unstable across three runs — 2.1, 5.3 and 12.4 seconds — because creating tens of thousands of loose files in one directory has genuinely unpredictable tail latency. Reftable doesn't, because it isn't writing one file per ref.
  • Disk: at 10,000 refs, 40MB across 10,000 files versus one 266KB table plus a 43-byte list. At 50,000 refs, 198MB versus 1.4MB.
  • Reads: only about 1.4x better. git for-each-ref over 10,000 refs went from 180–183ms to 132–134ms; at 50,000 refs, 897–943ms to 636–714ms. Fine, not transformative.
  • Fetch and push: the 2.51 release notes claimed 22x and 18x improvements on a 10,000-ref repository. Reproducing it locally over file:// got 2–4x at 50,000 refs and nearly nothing at 10,000. Treat the official multipliers as a number measured on one specific rig — likely a network transport where fixed round-trip cost dominates — not as a promise that travels to your laptop.

So the realistic pitch is: big win on bulk writes and disk, modest win on reads, and unproven on the multiplier you were probably most excited about.

The axis nobody benchmarks: contention

Now run 150 distinct git update-ref processes at the same time against the same freshly initialised repository, each targeting a different branch so there's no logical conflict for Git to detect.

  • 50 concurrent writers: both backends succeed 50/50. Files takes 37–43ms; reftable takes 74–108ms. Reftable is simply slower, which is expected — every write funnels through one lock.
  • 100 concurrent writers: 56 of 100 succeed. Both trials. The other 44 exit non-zero with fatal: ... cannot lock references.
  • 150 concurrent writers: files succeeds 150/150 in 106–119ms. Reftable succeeds 54–70 out of 150 across five trials. Failure rates between 30% and 63%, never zero, never total.

That's the cliff. Somewhere between 50 and 100 simultaneous writers, reftable goes from "slower but correct" to "partially fails," and it stays that way. Not deterministically, which makes it worse: the same job passes on Tuesday and corrupts your release tags on Wednesday.

The part that should worry you most: partial failure

If all 150 updates failed, every script and every engineer would notice immediately. What actually happens is that 96 of your 150 refs land and 54 don't, leaving a half-built ref namespace, a red job, and a repo state that is neither the old one nor the new one.

That breaks the single most useful assumption in automation: if the command exited zero, the work is done. Partial success quietly turns a ref-creation step into a thing that needs reconciliation logic, and almost nobody writes reconciliation logic for "create branch."

Here are the workload shapes that generate this kind of burst, in rough order of how often I see them:

  • CI matrices. A 40-leg build matrix where each leg tags its artifact or creates a branch, all launched by the same workflow trigger.
  • Monorepo tooling. A task runner that fans out per-package and updates a ref per package when it finishes.
  • Bot automation. A GitHub App or triage bot that creates a branch per incoming issue or PR, in a loop with parallelism bolted on for speed.
  • Release scripts. Tagging several components at once, often with & or xargs -P because a previous engineer got impatient.
  • Scaffolders and fork farms. Any service that stamps out hundreds of refs when someone clicks "create project."

If none of these exist in your world, reftable is close to a free win. If two of them do, you have a migration risk that a single-writer benchmark will never show you.

Three questions before you flip the default

  1. How many ref writes does one job fan out, and are they ever parallel? Grep your scripts for git branch, git tag, git update-ref, git push, then look for the parallelism: &, xargs -P, matrix jobs, worker pools. If the answer is "one writer, serially," you're done. If not, keep reading.
  2. Which pain are you actually solving? Reftable buys 10–60x on bulk writes and roughly 140x on ref disk, and about 1.4x on reads. If your repo has 300 refs and your engineers complain about for-each-ref being sluggish, the table says you're buying almost nothing and taking on new failure modes.
  3. Do you control every writer? Third-party tools, IDE plugins and internal services all write refs too. You can serialise your own release scripts; you can't serialise someone else's plugin.

If you switch anyway, do these five things

  • Set reftable.lockTimeout deliberately. The default is 100 — milliseconds of patience, which is short for a burst of simultaneous writers. 0 means don't retry at all; -1 means keep trying indefinitely. If your writers are bursty but short-lived, a few seconds of patience converts most failures into latency.
  • Batch your ref updates. git update-ref --stdin is atomic all-or-nothing and takes one lock acquisition instead of N. Both backends already behave identically here, so batching is a pure win and it's the single highest-leverage change you can make.
  • Make ref creation a queue, not a race. One job per repository owns tagging; everything else requests a tag from it. Boring, and it removes the whole failure class.
  • Treat a non-zero exit as fatal, and retry on this specific string. A retry with jitter on cannot lock references is safe, because the failed operation didn't apply. A retry on a generic failure is not.
  • Decide per repository. The ref backend is repository-level config. Migrate one noisy, ref-heavy repo first, watch it for a month under your worst parallel job, then decide about the org.

The lesson is bigger than Git

Every "10x faster" default migration is a benchmark of one axis, run on the workload the author had. The useful question to ask before you adopt it is: what resource just became shared?

Reftable turned per-ref locks into one append point plus compaction. Sqlite to Postgres swaps per-process file locks for a shared transaction log. A per-service log file becomes one shared writer. A single Redis counter under a fan-out becomes the floor of your throughput. In each case the fast path is genuinely fast, and the failure appears only when the writers overlap — which is precisely when your tests are running alone on a developer laptop and never happens in the benchmark you read.

So measure the overlap. Run your worst parallel job against a candidate backend, at 50, 100 and 150 concurrent writers, and log the exit codes, not just the wall clock. If the numbers hold, you get the 10x. If they don't, you learned it in a test repo instead of a release branch.

Key Takeaways

  • Reftable's headline numbers are real: 10–60x on bulk ref writes, 198MB down to 1.4MB on disk, about 1.4x on reads. The 22x fetch claim didn't reproduce locally (2–4x at best).
  • The failure mode only appears under concurrency. 50 simultaneous writers: both backends fine, reftable slower. 100 writers: 56/100 succeed. 150 writers: 54–70/150, every trial.
  • Partial failure is the real risk. A half-written ref namespace breaks the assumption that a zero exit code means the work is done.
  • Before switching, count how many ref writes a single job fans out and whether they run in parallel. Serial writers are a free win; parallel writers need testing.
  • If you switch: raise reftable.lockTimeout, batch through git update-ref --stdin, serialise tag creation, retry only on the lock error, and migrate one repository at a time.
  • Generalise the habit: whenever a new default promises 10x, ask what resource became shared, then benchmark the overlap instead of the average.

[ Call to action ]

Ready to scale your engineering team?

Tell us the roles you need to fill and we'll get back within 24 hours.