This codebase has had multiple dedicated performance passes — cold-start repo page loads were measured at several seconds before these existed. This doc covers the caching layers, the instrumentation convention used to diagnose slowness, and the biggest fixes made, as case studies for the kind of thing to watch for when adding new code.
-
TanStack Query (client + SSR) — tiered
staleTime/gcTimeper data shape, all defined insrc/lib/query-options.ts:SESSION_STALE_TIME(60s) — auth session.DEFAULT_STALE_TIME(2min) — most repo/issue/PR list data.LONG_LIVED_STALE_TIME(10min), with a longergcTime(30min) — branch lists and similar data that changes rarely. The longergcTimematters: React Query's defaultgcTime(5min) is shorter than a 10-minutestaleTime, so an unobserved entry (a tab the user navigated away from) would get garbage-collected before it ever went stale, silently forcing a refetch that thestaleTimesaid shouldn't be needed yet.IMMUTABLE_STALE_TIME(Infinity) — commits and their diffs, addressed by SHA. Content-addressed and immutable, so once fetched they never need a background refetch.
When adding a new query, pick the tier that matches how often the underlying data actually changes — don't default to the shortest one out of caution; that's how a page ends up re-fetching data on every navigation for no reason.
-
repo-access.ts's access-decision cache — 4-second TTL, keyed by(repoId, userId), with in-flight coalescing. A single tree-page load fans out to 4+ server functions in parallel that each independently need "does this user have access to this repo" — without this, that's 4+ redundant DB round trips for the identical answer. Short TTL is deliberate: this is a perf cache, not a correctness cache (a revoked collaborator should take effect in seconds, not linger for a request's lifetime). See authentication.md. -
git-cache.ts's in-process parsed-object cache (server, per-process) — stores JS values directly (getCachedObject/setCachedObject), used for both real results (tree listings, commit logs) and negative/stat markers, avoiding a JSON round-trip on every hit. The rawBuffercache for git object bytes read from R2 used to live here too, but has since moved intogit-fs-s3's owncreateCachedStore(composed ingit-fs.ts) as part of extracting the R2 backend into that published package (see item 4 below).git-fs.ts's per-key TTL override — the R2 read cache's TTL (GIT_CACHE_MAX_SIZE/GIT_CACHE_TTL-tunable, default 1h) is right for content-addressed object reads (a given key's bytes never change) but wrong for the mutable parts of a gitdir: a ref's value moves on every push, and theobjects//objects/pack/directory listings grow on every push, while the key naming either stays the same. Since this cache is in-process per server instance with no cross-instance invalidation, a warm instance that isn't the one handling a given push could keep serving a pre-push ref, or fail to discover a freshly pushed pack exists at all (misreporting its commits as "missing from storage"), for up to the full TTL.git-fs.ts'srefAwareTtl(passed asgit-fs-s30.3.4+'sttlForKeyoption) givesHEAD,refs/*, and the two listing paths a 5-second override instead — cheap, since each is one small object or a bounded listing, and safe, since everything downstream (tree/commit/blob reads keyed by the sha a fresh ref resolves to) still gets the full-length cache benefit once the structure itself is current. -
Negative-result and loose-object-hint caching — isomorphic-git repeatedly probes paths it expects might not exist (ref candidates, loose-object paths before falling back to pack search, directory-existence checks before every read). This now lives in
git-fs-s3itself (createGitFs'slooseObjectHints/isStructurallyAbsentoptions, wired ingit-fs.ts) rather than the app's own formergit-r2-backend.ts, which was extracted into that published package — see git-storage.md for the current architecture.detectLooseObjectsHintstill answers "does this repo have any loose objects at all" once per repo with a single bounded LIST call, so a fully-packed repo (the common case) never even attempts a loose-object probe against R2 — this was the single biggest fix to commit-log cold-load time, and (once wired into the actual clone/fetch-serving path, not just commit-log browsing) to cold clones too. -
getCommitLog's per-head-SHA result cache (git-history-ops.ts) — caches the deepest commit-chain walk seen for a resolved head SHA and slices/reuses it for shallower or repeated requests, since walking a commit chain is inherently sequential and R2-round-trip-bound. -
R2 request coalescing —
git-fs-s3'screateCachedStore(coalesceoption, on by default; formerlygit-r2-backend.ts's ownpendingDownloads/pendingStatsmaps before the R2 backend was extracted into that package) ensures concurrent reads for the same not-yet-cached R2 key (e.g. 100 object reads all wanting the same pack file mid-walk, or several concurrent ref/branch lookups that each independently stat the gitdir root) share one backend call instead of firing one each — on a cold cache, none of them can see a result the others haven't produced yet; measured 4 concurrent callers as 4 realHeadObject+ListObjectspairs against the identical key before this existed. -
repositories.ts's repo-row cache (fetchRepoRowByName, 5-second TTL- in-flight coalescing) — a single-join, cached lookup that both the web
UI's
getRepositoryByNameand git-auth'sfindRepositoryByNameshare. Every git HTTP operation makes at least two requests (info/refs, then upload-pack/receive-pack), andfindRepositoryByNameused to be two sequential, fully uncached queries paid fresh on every single one of them; routing it through the same cache the web pages already used means the second request of a push typically gets a free hit.
- in-flight coalescing) — a single-join, cached lookup that both the web
UI's
-
syncRepositoryToR2Unlocked's ETag-compared skip forHEAD/config(git-repo-storage.ts) — the existing objects/ skip (item above) only ever applied to content-addressed objects;HEADandconfigare set once at repo creation and essentially never change again, but were re-uploaded unconditionally on every push regardless. A non-multipart PUT's ETag (already returned by theListObjectsV2call this function already makes) is the MD5 of the body, so comparing against that catches the common "unchanged" case without an extra R2 round trip to check. Live-profiled: ~300ms saved per file, per push, for every push after the first.
Route loaders and server functions favor Promise.all wherever calls don't
have a real data dependency on each other. Some concrete examples already in
the codebase, worth matching the shape of when adding new code:
- The tree page's loader only
awaitsrepositoryByNameQueryOptions(fast — one DB row) —repositoryBranchesQueryOptions,repositoryFilesQueryOptions,repositoryLastCommitsQueryOptions, thelimit: 1repositoryCommitsQueryOptionscall, and the README content query are all fire-and-forgotten (ensureQueryData(...).catch(() => {}), not awaited), so the route commits and the page mounts the momentreporesolves instead of blocking on whichever of those four is slowest. Each section (FileTable,CommitSummaryBar, the branch selector, ...) reads the same query key client-side via its ownuseQuery, picks up the already-in-flight fire-and-forgotten request, and renders a skeleton matching its own final layout until that specific piece resolves — this used to be one blockingPromise.allbefore the loader was restructured to stream; see server-functions.md. git-fs-s3/http'slistAllRefs(used bygit-http-iso.ts's thin wrapper) resolves branches, tags, HEAD's oid, and the current-branch symref all in parallel, then resolves every branch/tag's oid in parallel too.git-fs.ts'sprefetchAllPacks(a thin wrapper aroundgit-fs-s3'sGitFs.prefetchPacks) downloads every pack file in parallel before a sequentialgit.logwalk starts, rather than letting isomorphic-git fetch packs lazily and serially as the walk needs them. As ofgetTreeFromRef/getCommitLog's current wiring this now fires unconditionally on a cache-miss tree read, and unconditionally (not depth-gated) for any commit-log walk too — see git-storage.md.git-http-iso.ts'shandleReceivePackIsoapplies every ref update in a push (compare-and-swap check, then write/delete) in onePromise.all— each ref only touches its own ref file, so a multi-ref push (git push --all/--tags) applies them all concurrently instead of one at a time.git-last-commit.ts'sgetLastCommitsForTreewalks commit history in fixed-size batches, prefetching each batch's tree reads in parallel before processing that batch sequentially (the "which entries are still unresolved" state must advance commit-by-commit for correctness) — this two-phase shape (parallel prefetch, then sequential resolve) was previously the single largest contributor to slow repo page loads before it existed (measured: ~36s of a ~39s cold load), and should be preserved if this function is ever touched again.
Some things are inherently sequential and can't be parallelized away — a commit chain walk only reveals the next oid to fetch after reading the current commit, and a resolved access decision has to exist before a gated query runs. The fix in those cases is caching/prefetching around the sequential part, not fighting the dependency.
git-fs-s3/http's repackRepository (called from handleReceivePackIso via
applyReceivePack) consolidates a push's fragmented pack files into one, but
for a long time the consolidation only ever happened locally — nothing told
R2 to delete the packs it had just made redundant (syncRepositoryToR2Unlocked
deliberately never deletes anything under objects/, for good reason — see
git-storage.md). The result: every single push left one
more permanent pack file in R2, forever, making both future pushes (more to
hydrate) and clones (more packs to prefetch) slower as history grew —
completely defeating the point of consolidating in the first place.
Fixing that alone wasn't enough, because repackRepository's own safety check
(only delete old packs if the new pack's object count is at least as high as
the old packs' combined count — a guard against an incomplete traversal
silently losing objects) was structurally broken: the moment packs ever
overlap in content, the "old" side double-counts objects that appear in more
than one old pack, so it almost always comes out higher than the new
deduplicated count. Once that happened once, it happened every time after —
a permanent, silent lockout from ever consolidating again. This is why a
repo can accumulate many packs despite the consolidation code running (and
"succeeding," from its own perspective) on every push.
The lesson: a safety check's assumptions can quietly stop holding once something it depends on has already partially failed once. The count comparison assumed old packs were always disjoint — true until the very first time the check itself declined to clean up. The fix replaced the count comparison with a check on the actual property that matters (traversal completeness — did every object the walk visited get read successfully), which doesn't have that failure mode.
deleteStalePacksFromR2 (the R2 half of the fix above) used to run after
withReceivePackLock's lock was released, on purpose — so a push's HTTP
response didn't wait on cleanup that isn't needed for the push itself to be
correct. That opened a window: a concurrent reader's objects/pack/
directory listing (a clone/fetch's collectReachableOids, or another push's
own hydration step) could be taken before that deletion ran and still be in
use after it completed, naming pack files that no longer existed.
This was invisible under the testing that normally exercises this code
(sequential pushes, one clone at a time) — it only showed up under a
deliberately adversarial load test: 15 clones running concurrently against a
repo being pushed to in a tight loop, each push crossing the repack
threshold. Most of those clones failed with fatal: bad object <sha> /
"remote did not send all necessary objects" — always the same oid within a
given run, and always one that cloned fine moments before or after. git fsck on every successful clone confirmed the object was never actually
missing anywhere; the failing requests were racing a real but narrow
transition window, not observing real data loss.
Two places read a pack listing this way, and the fix differs between them:
writeRemoteFilesToDisk(git-repo-storage.ts, a concurrent push's own hydration step) races this window only because the cleanup ran outside the write lock. Now thatwithReceivePackLockrunsdeleteStalePacksFromR2via itsafterSynchook — still inside the same lock the sync itself holds — a concurrent push can no longer even start hydrating until this one's cleanup has finished, closing this side of the race entirely. It still tolerates a 404 as "superseded" rather than fatal, as defense in depth, but shouldn't actually hit it via this path anymore.collectReachableOids(git-fs-s3/http, serves clone/fetch) races this window unavoidably: reads are deliberately lock-free (see git-storage.md's "Reads:git-fs.ts"), so no amount of moving cleanup around inside the write lock changes when a concurrent read observes it. Taking the write lock on every read would reintroduce exactly the latency read/write separation exists to avoid, for a race that's rare and whose content is never actually gone — so this side keeps its existing fix instead: retry once, after a short delay (long enough for the deletion's own cache invalidation to land), tolerating a 404 as "superseded" rather than treating it as fatal.
The lesson: not every reader of state a background cleanup touches is equally reachable by "just take a lock." A writer racing another writer's cleanup can be fixed by broadening what the lock covers, cheaply. A lock-free reader racing a writer's cleanup can't be, without giving up the reason it was lock-free — that side has to tolerate the transient inconsistency instead, and only gets to if you can show it really is transient (here: the replacement pack is always written before the old ones are removed, so nothing a reader wants is ever actually gone).
prefetchAllPacks (git-fs.ts, wrapping git-fs-s3's GitFs.prefetchPacks)
exists because walking a commit chain is inherently sequential — you only
learn the next object to fetch after reading the current one — so without
prefetching, a repo sitting on more than one pack pays one R2 round trip
per pack, serially, as the walk happens to discover it needs that pack's
objects. git-history-ops.ts/git-last-commit.ts/git-file-history.ts (the
web UI's commit-log, last-commit-per-file, and file-history reads) all call
it before their own walks.
handleUploadPackIso (git-http-iso.ts — serves real git clone/git fetch, not the web UI) has an object-graph walk with the exact same
shape (collectReachableOids, in git-fs-s3/http), but never called it —
this was invisible in code review because nothing about the code looked
wrong; collectReachableOids is correctly, fully parallelized internally
(concurrent, deduplicated promise-per-oid graph traversal). The gap only
showed up under live profiling against a real R2 bucket: on a 2-pack repo,
collectReachableOids alone cost 744.7ms, with the pack downloads it
triggered happening essentially serially as the walk's early, single-threaded
stages (starting from one "want" tip commit) discovered it needed first one
pack, then the other. Adding the same prefetchAllPacks call to
handleUploadPackIso's existing beforeWalk hook (already used for the
loose-object hint) dropped that same step to 20.1ms — a ~97% reduction — and
took end-to-end git clone against a warm local dev server with real R2 from
2.1s to 0.65s.
The lesson: when the same structural problem has already been solved at
some call sites, audit every place with the same shape for the fix, not just
the ones a doc happens to mention — a written claim that an optimization is
"wired into" a given path is a statement about when it was written, not a
guarantee it stayed complete as new call sites were added. greping for the
fix's own call sites (prefetchAllPacks() is more reliable than trusting
prose. See scripts/perf-setup.ts/scripts/perf-page-load.ts for the
profiling harness this was found with — real git CLI operations and direct
calls to the read-path functions (bypassing createServerFn's request-context
requirement) against a local dev server with real R2 credentials, reading
perf-log.ts's breakdown from the dev server's own stdout.
Two parallel modules, same idea, different sides of the request:
src/server/perf-log.ts(server):perfContext(label, fn)wraps acreateServerFnhandler in an AsyncLocalStorage-based context (so nested calls don't need it threaded through every function signature);perfStep(label, fn)times an awaited sub-call and nests under the active context;perfR2(label, fn)(used insider2-operations.ts) additionally tallies R2 call count/time onto the context, printed in theperfContextsummary line at the end (e.g.■ done in 2721ms (r2: 39 calls / 16378ms, cache: 123 hit / 36 miss)).src/lib/perf-log.ts(client/SSR):perfTime/perfMark, used in route loaders andquery-options.tsqueryFns — prefixed[perf:ssr]or[perf:client]so the same request's client-perceived latency can be compared against the server-side breakdown printed by the module above.
Follow this pattern when adding a new server function on a user-facing read
path: wrap the handler in perfContext, wrap each meaningfully-awaited
sub-call in perfStep. This is what made every fix described above possible
to actually find — the dev server's logs show a full timing breakdown for any
slow page load, rather than a single opaque total.
The tiered staleTimes above are a trade-off: the longer they are, the
longer a page can keep showing data that's genuinely out of date if someone
else pushed in the meantime. Rather than shortening staleTime (which
undoes the caching win for everyone, all the time, to cover the rare case
someone else just pushed), useBranchUpdateBanner
(hooks/use-branch-update-banner.ts) adds refetchInterval: 60_000 and
refetchIntervalInBackground: true (plus refetchOnWindowFocus: true) as
extra observer options on top of repositoryLatestCommitQueryOptions
(query-options.ts — a limit: 1 repositoryCommitsQueryOptions call),
rather than standing up a second, separate "just the SHA" query.
refetchIntervalInBackground matters specifically for a backgrounded tab —
without it, refetchInterval alone still pauses while the tab isn't
focused, so a push landing then wouldn't surface until the user refocuses
and refetchOnWindowFocus happens to fire; with it, the banner is already
correct the moment they come back. A depth-1 commit-log walk is already as
cheap as a bare ref resolve (git-history-ops.ts's PREFETCH_PACKS_MIN_DEPTH
is 1 — effectively no gate, every cache-miss walk prefetches regardless of
depth; see server-functions.md), so there's no perf
reason to keep a minimal endpoint around just to avoid the walk.
This matters because it's the same query/cache entry that
CommitSummaryBar (the tree page's "latest commit" line) reads for its own
display: the banner's poll is what keeps that display fresh, for free, with
zero extra requests — one query serves both "is there something new" and
"what is the something new," instead of two independently-resolved answers
to "what's HEAD" that could in principle disagree.
useBranchUpdateBanner compares each poll against the SHA seen when it
started watching and, on a mismatch, shows BranchUpdateBanner instead of
silently continuing to serve the stale cached tree/commit data. Clicking
Reload awaits invalidateQueries(["repos", repoId]) — every repo-scoped
query key is nested under that prefix (see
server-functions.md), so one partial-match
invalidation busts everything a push could have changed, and (with
invalidateQueries's default refetchType: "active") only refetches
whatever's actually mounted right now, not every cached-but-unmounted query
for that repo. Awaiting it (rather than firing-and-forgetting) is what lets
the banner know it's actually safe to dismiss itself — every mounted query
for this repo, including the slower ones, is guaranteed to have finished
refetching by the time hasUpdate clears, not just the fast poll.
This is the general pattern for any future "long cache, but tell me if it's
actually gone stale" need: pile the always-polling refetchInterval onto
the same query that already serves the real data whenever a live view of
that data doubles as its own freshness signal — only reach for a genuinely
separate minimal query if the display data itself is too expensive to poll
on a timer.
Every R2 call goes through retry-with-exponential-backoff-and-jitter
(withRetry) and a circuit breaker (5 consecutive non-404 failures within 30s
opens the circuit, failing fast instead of piling up retries against an R2
outage). 404s are explicitly excluded from tripping the breaker — a missing
object is expected, everyday behavior, not a sign R2 itself is unhealthy.
getR2Client() (src/lib/r2.ts) is a lazily-created singleton S3Client
with an explicit keep-alive https.Agent — a single page load can fire
hundreds of object reads, so TCP connection reuse matters; never construct a
second S3Client.
Heavy, conditionally-rendered client dependencies are React.lazy-loaded at
every call site rather than imported directly: MarkdownRenderer
(react-markdown+remark-gfm+rehype-highlight, ~324KB) is lazy-loaded on
the tree page (repo home — the most-visited route in the app, most of whose
visits don't render a README at all) and in CommentCard; CodeViewer
(Shiki) is lazy-loaded on the blob page. Follow the same lazy(() => import(...)) + Suspense pattern for any new heavy, conditionally-rendered
dependency — check the production build's chunk output (pnpm build, look at
.vercel/output/static/assets/) to confirm a new heavy import actually lands
in its own chunk rather than inflating a hot route's critical bundle.