Skip to content

Caching

createCachedStore wraps any ObjectStore with an in-process LRU. Git objects are content-addressed, so a hit is always correct data forever — refs and other mutable paths need a much shorter TTL instead, via ttlForKey (below).

createCachedStore(store: ObjectStore, options?: CacheOptions): CachedObjectStore
ParameterTypeDescription
storeObjectStoreThe store to wrap.
options?CacheOptionsSee below.

Returns: CachedObjectStore

A CachedObjectStore is a normal ObjectStore plus one extra method: invalidate(prefix).

Option Default What it does
maxBytes 50 MiB Total cache budget.
maxEntryBytes maxBytes / 10 Largest single entry admitted — keeps one huge packfile from evicting the whole working set.
ttlMs 60 000 Entry time-to-live.
ttlForKey Per-key/prefix TTL override — see below.
cacheMisses false Also cache “not found” results.
cacheLists false Also cache list() results (directory listings, existence probes).
coalesce true Collapse concurrent identical requests into one backend call.
onHit / onMiss Instrumentation hooks, called with the key.

(key: string) => number | undefined — called for every get/head admission and every list() result, keyed by the object’s key (for get/head) or the listed prefix (for list()). Return a number to override ttlMs for that specific entry; return undefined to fall back to the default.

A single ttlMs tuned for object reads is right for object reads — the whole point of content addressing is that a hit is correct forever — but it’s wrong for the handful of keys in a gitdir that are mutable:

  • Refs (refs/heads/<branch>, HEAD) — the same key’s value changes on every push.
  • Directory listings under objects/ (particularly objects/pack/) — the set of packs grows on every push, even though no individual pack’s bytes ever change.

A ref is one small object and a pack listing is one bounded list() call, so re-checking either far more often than ttlMs costs little. Everything downstream of a fresh ref or listing — the tree, commit, and blob reads keyed by the sha it resolves to, or the specific pack name a fresh listing named — still gets the full ttlMs cache benefit once the structure itself is current:

const cached = createCachedStore(store, {
ttlMs: 3_600_000, // 1h — safe for object reads
ttlForKey: (key) =>
/\/(HEAD|refs(\/|$)|objects\/(pack\/)?$)/.test(key) ? 5_000 : undefined,
});

Loose-object probes on a packed repository are always misses — every one of them is otherwise a wasted round trip on every single request. Turning this on is close to free performance, with one condition:

Caches list() results — which backs both readdir() and existence probes. Writes made through this same cached store keep listings consistent automatically. There’s one deliberate asymmetry worth knowing:

A non-empty limit: 1 probe (the shape of “does this directory have anything in it”) survives writes underneath it — a write can add entries to a directory, but it can’t make a directory that already has something in it stop existing, so that specific cached answer stays valid regardless.

After a write that happens outside this cached store (another process, a bulk sync), call invalidate(prefix) — see the production stack guide.

Multiple concurrent callers requesting the same key collapse into one backend request, sharing the result. On by default — a page render that fans out N requests for the same object (e.g. N callers all resolving the same ref) pays for one network round trip, not N.

cacheMisses and cacheLists trade read traffic for a window — bounded by ttlMs — in which another process’s writes are invisible here. This is fine in exactly two situations: each repo is served by one process at a time, or every external write path calls invalidate(). Outside those two, don’t turn them on.