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.
copy true Return a fresh copy per hit so callers can’t mutate cached bytes. Set false when readers never mutate (isomorphic-git doesn’t) — otherwise every hit copies the whole entry, megabytes for a packfile.
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.

classifyGitdirPath turns a gitdir-relative path into a typed entry (head, refs, pack-dir, pack-file, loose-object, …) and isMutableGitdirEntry is true exactly for the mutable ones above.

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:

import { classifyGitdirPath, isMutableGitdirEntry } from 'git-fs-s3';
// Keys here are `repos/<owner>/<repo>/git/<path inside the gitdir>`.
const pathInGitdir = (key: string) => key.split('/').slice(4).join('/');
const cached = createCachedStore(store, {
ttlMs: 3_600_000, // 1h — safe for object reads
ttlForKey: (key) =>
isMutableGitdirEntry(classifyGitdirPath(pathInGitdir(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.