Skip to content

perf(router): render/caching pipeline optimizations - #658

Merged
ivogt merged 1 commit into
mainfrom
perf/render-cache-pipeline
Jul 4, 2026
Merged

ivogt merged 1 commit into
mainfrom
perf/render-cache-pipeline

Conversation

@ivogt

@ivogt ivogt commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Problem

Three independent audits of the render/caching pipeline (server request path, cache layer, client navigation) surfaced redundant hot-path work. The most consumer-observable costs: every document request resolved the route twice (classification + match), every "use cache" call — including fresh hits — paid the React Flight reply encoder just to build its key, N concurrent misses on one key after a tag invalidation each re-ran the cached function and re-wrote the store, the Vercel store serialized every large envelope twice per write and rewrote the whole payload on every stale read, and every production client navigation eagerly built O(segments) debug strings that were thrown away unread.

What a consumer sees differently

Nothing to adopt — no API changes are required. Observable effects:

  • Lower TTFB on document loads (route resolution, manifest restore, and URL parsing each happen once).
  • "use cache" under concurrent traffic: a cold or just-invalidated popular key executes once, not N times.
  • MemorySegmentCacheStore no longer grows without bound: new maxEntries option, default 1000 per family (segments / responses / items / shells), FIFO eviction with tag-index cleanup.
// Before: unbounded
new MemorySegmentCacheStore({ defaults: { ttl: 60, swr: 300 } });

// After: bounded by default; tune or raise per deployment
new MemorySegmentCacheStore({ defaults: { ttl: 60, swr: 300 }, maxEntries: 5000 });

End-to-end example

// api/get-product.ts
export async function getProduct(ctx: HandlerContext, id: string) {
  "use cache";
  return db.products.find(id);
}

Before: every call — hit or miss — ran encodeReply([ctxKeyParts, id]) to build the key; 20 concurrent first requests for product 42 ran db.products.find 20 times and wrote the store 20 times. After: the key is a stable JSON string (use-cache:<id>:j:[...]), one leader runs the query, 19 followers deserialize the leader's serialized result into their own responses, and the store is written once.

Semantics table

Case Before After
Document request route resolution resolveRoute in classify + again in match() once; match() reuses the classified snapshot (isSSR-flag guarded, HMR falls back)
loading(c, { ssr: false }) per-isSSR manifest partitions unchanged (partition scheme intact; snapshot reuse is flag-matched)
"use cache" key, JSON-safe args Flight reply encoder per call stable-stringify in disjoint :j: namespace; existing entries for JSON-safe args cold-start once
"use cache" key, non-JSON-safe args Flight reply encoder unchanged (same keys, no cold start)
Concurrent "use cache" misses, same key N executions, N writes 1 execution, 1 write; followers get synthetic hits; a thrown leader propagates and followers retry fresh
Vercel store write stringify to measure + client stringifies again one stringify; stored as string; reads accept legacy object envelopes (no cold start)
Vercel stale read whole payload rewritten to push staleAt; every reader revalidates tiny {key}:lock written; only the claiming reader gets shouldRevalidate: true
Memory store growth unbounded FIFO cap per family (default 1000); lazy TTL expiry unchanged
x-document-cache-status header always present, full header clone per hit always present; in-place set, clone only for immutable headers
Client navigate-away mid-handle-stream partial handle data kept in history cache unchanged (bucket arrays shared by reference; containers still cloned per yield)

Code changes

45 files, +2563/−364. Server: request-classification.ts (resolve under the render's isSSR flag), match-api.ts (full-path snapshot reuse mirroring the partial path), manifest.ts (alias cached Map; reassign-not-clear safety pin), middleware.ts/match-handlers.ts (clean URL from request context), match-result.ts (fused passes). Cache: cache-runtime.ts (in-flight Map<key, Promise<CacheEnvelope>> + jsonSafeKeyPart fast key), memory-segment-store.ts (maxEntries), vercel-cache-store.ts (single-serialize + claimRevalidation lock key), document-cache.ts, segment-codec.ts. Client: debug-flag guards across segment-reconciler/partial-update/etc., navigation-store.ts (container-only cloneHandleData + owner-guarded single-scan write), prefetch/cache.ts (expiry watermark), event-controller.ts (memoized referentially-stable snapshots), Link.tsx, navigation-client.ts/prefetch/fetch.ts (single cookie parse).

Deliberately deferred (design passes needed, tracked separately): Flight-string hit splicing (serve cached segments without deserialize→reserialize), renderSegments per-segment element reuse, temporalLazyRefs streaming.

Test plan

  • Unit: 4867 router tests + all consumer-app suites + RSC configs (pnpm run test:unit:all) green. New suites: match-api-classified-reuse (snapshot reuse/fallback matrix), manifest-cache poisoning regression (proven red-before-green against in-place clear()), cache-runtime-inflight (leader/follower/rejection/tags+handles), cache-runtime-fastkey (determinism, fallback, tainted-ctx keys), memory-store-eviction, segment-codec (multibyte split), Vercel store legacy-envelope + lock tests, clone-handle-data-cow, prefetch-cache-watermark, event-controller-snapshot, rango-state read-once tests.
  • Semantic matrix: 64 passed (mandatory — middleware.ts touched).
  • Typecheck (15/15 projects), oxlint --deny-warnings, oxfmt, e2e-bucketing, .gen.ts clean.
  • CI full e2e matrix (dev + production across test-app, cloudflare-basic, vercel apps) — existing suites pin the affected contracts in both modes (blog-cache/cache-tag read x-document-cache-status in production; use-cache*, cache*, prefetch and handle-streaming suites cover the touched paths).

Notes

  • The "use cache" key-namespace change means one cold miss per JSON-safe-args entry on first deploy; TTL windows make this a non-event, but flagging it.
  • Vercel stale-read behavior intentionally changed from "every stale reader revalidates (dampened by re-stamp)" to "one claimant revalidates, others serve stale": fewer duplicate revalidations, no payload rewrite; the lock is best-effort (no CAS) exactly like the mechanism it replaces.
  • maxEntries default (1000) is the only default-behavior change; deployments intentionally holding more per family should raise it.

@pkg-pr-new

pkg-pr-new Bot commented Jul 4, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@658

commit: a3d244a

Server request path:
- classifyRequest resolves the route under the flag the eventual render
  uses (isSSR:true for full-render mode), and createMatchContextForFull
  reuses the classified snapshot like the partial path already did —
  document requests no longer resolve the route twice
- loadManifest cache hits alias the cached Map instead of copying it
  entry-by-entry; the fresh path reassigns instead of clear() so a prior
  alias can never poison the module cache
- match/middleware contexts reuse the request context's clean URL
  instead of re-parsing request.url (~6x per request + once per
  middleware before)
- buildMatchResult fuses its ~6 O(n) segment passes

Cache layer:
- "use cache": in-flight dedup for concurrent misses — one leader runs
  the function, followers deserialize their own copy of the serialized
  envelope (miss stampede after invalidation eliminated)
- "use cache": JSON-safe key args skip the Flight reply encoder via a
  deterministic stable-stringify under a disjoint :j: key namespace
  (JSON-safe-arg entries cold-start once after upgrade)
- MemorySegmentCacheStore: per-family maxEntries FIFO cap (default
  1000) with tag-index cleanup — no more unbounded growth
- VercelCacheStore: envelopes serialize exactly once (size check +
  cache.set share one string; reads accept legacy objects), and stale
  reads write a tiny {key}:lock companion instead of rewriting the
  whole payload
- document-cache mutates the status header in place (clone only for
  immutable headers); streamToString joins chunks instead of O(n^2)
  string concat

Client pipeline:
- debug template literals (joins/maps/spreads) guarded behind the flag
  instead of built eagerly on every production navigation
- cloneHandleData shares immutable bucket arrays (containers still
  cloned); processHandles folds the ownership probe + write into one
  historyCache scan per streamed yield
- prefetch cache expiry sweep gated behind a soonest-expiry watermark
- event-controller getState/getActionState return memoized snapshots
  with stable references; empty inflight list shared
- Link caches the (hover: none) MediaQueryList; rango-state cookie
  parsed once per navigation/prefetch operation
@ivogt
ivogt force-pushed the perf/render-cache-pipeline branch from 359ff9b to a3d244a Compare July 4, 2026 13:34
@ivogt
ivogt merged commit 562202c into main Jul 4, 2026
30 checks passed
@ivogt
ivogt deleted the perf/render-cache-pipeline branch July 4, 2026 13:46
ivogt added a commit that referenced this pull request Jul 4, 2026
…, dev prerender memoization (#662); map loader-snapshot.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant