Skip to content

Persist the ssr environment's transform results across restarts (OJ_SSR_TRANSFORM_CACHE=1, draft) - #317

Draft
bittermandel wants to merge 4 commits into
mainfrom
bittermandel/ssr-transform-cache
Draft

bittermandel wants to merge 4 commits into
mainfrom
bittermandel/ssr-transform-cache

Conversation

@bittermandel

@bittermandel bittermandel commented Oct 6, 2026 •

Copy link
Copy Markdown

Draft, default off. Adds OJ_SSR_TRANSFORM_CACHE=1, which persists the ssr environment's transform results in the cache root (ssr-transform-cache.json). On the next start it seeds Vite's module graph with them, so files whose bytes did not change skip every plugin transform. The open items below are why this is a draft.

Why

On a Cloudflare-runner TanStack Start app (Lovable's web/, about 3,400 SSR modules), the first request after listening takes about 13 s. Measured locally (laptop, warm caches, LOAD_DEV_SECRETS=false):

  • sample of the oj process during the first SSR: the plugin host's oj-js-engine thread is 100% busy for the whole window. workerd's main thread is 91% idle (kevent).
  • Counting fetchModule calls in the plugin host: 11,050 calls for 3,357 distinct modules; 7,693 of them are cached: true re-validations.
  • Arrival times: from about 1 s to 11 s after start the runner issues only 0 to 40 fetches per second, because each one waits behind route transforms on the busy thread. Once transforms are cached it issues about 2,500 per second.
  • Where the thread's time goes: GC 18%, rolldown napi 18%, JSON.parse 8%, JS about 55% (Babel parse and traverse from the TanStack code-splitter, import protection and server functions; Vite sourcemap combining; import analysis).
  • Upper bound, measured by serving fetchModule results from disk with no module graph (not shippable): first /health went from 17.6 s to 10.4 s.

How

  • Seeding: on start, when the global key matches, entries whose file hash and file-only dependency hashes (addWatchFile) are unchanged are installed with moduleGraph._ensureEntryFromUrl(url, _, {id, meta}) plus mod.transformResult. doTransform then returns them without loading or transforming. Every import of a seeded entry gets a graph node (resolved with resolveUrl first, so a failed resolution never lands in Vite's url promise map) and edges both ways, all before any result is published, so a later edit to an unseeded import still reaches its seeded importers. If any of that fails, the snapshot is not used.
  • Staleness:
    • A changed file or file-only dependency marks the module stale, and its importers too, transitively.
    • A module whose source reads import.meta.env, or whose transformed code carries Vite's injected env object, is stale when the resolved env changed. This doesn't propagate, because importers don't embed it. cfg.env holds the commit SHA and commit time, so a global env key would void the cache on every commit.
    • Virtual (\0) modules are never cached, nor are .json/.map modules (Vite's import analysis skips them, so their watched files are never recorded).
    • The cache turns itself off (and logs it) when a non-Vite plugin has a transform with order: 'post': imports declared after import analysis never reach the graph.
  • Global key: plugin-host script bytes, the app's Vite version, root, mode, define, plugin names, the deps_ssr optimizer metadata, the config file plus configFileDependencies, and a hash of the sorted file list under the root (excluding dot-dirs, node_modules, dist), so adding or removing a file invalidates the cache (95 ms for 43k files on Lovable web/). An unreadable key input disables reuse.
  • Provenance: a file's hash, and the hashes of all its non-virtual imports (which covers addWatchFile deps), are taken when Vite stores the result (updateModuleTransformResult, which Vite skips for a transform that was invalidated mid-flight), not when the snapshot is written.
  • Validation: one malformed entry rejects the whole snapshot. The same validator runs at save time, so an odd entry is skipped instead of poisoning the snapshot.
  • Snapshot: one per boot, written once fetchModule has been quiet for 5 s after the first fetch, via tmp file and rename. OJ_NO_CACHE disables the cache.

Measured (same binary, flag on/off, alternating)

first /health 200, median (range) n
off 17.46 s (17.34–17.72) 6
on 12.70 s (12.61–12.73) 6

Re-measured on 87e18d0 (n=5, alternating, same laptop, which ran about 1 s slower overall that hour): off 18.53 s (18.30–19.11), on 13.84 s (13.61–14.33).

The table's timings are from ab1604c; e17273d changes only env detection, and the checks below were re-run on it. Seeding 4,297 modules takes about 380 ms, including parsing the 113 MB snapshot. Lovable devenv all-Ready, default profile, where web is the critical path: 28.29 s (28.0–28.51) off vs 22.72 s (22.66–23.23) on, n=3 each, measured with the same mechanism on an earlier build.

Checks run on the app (re-run on 87e18d0; adding a file seeds 0, removing it again hits):

  • A file edited while the server was down is served fresh (85 stale, including its importers).
  • A live edit is reflected.
  • GET / returns 200.
  • A file reverted while the server was down is served fresh.
  • An env-only change (VITE_GIT_BRANCH) re-transforms the 67 env-dependent modules, and the SSR page shows the new value.

Open items (blockers for leaving draft)

  • Stateful producers: a plugin whose transform fills in-memory state that a virtual module's load reads later breaks when that transform is skipped (the warm-transform-virtual.mjs pattern).
  • HMR analysis fields (isSelfAccepting, accepted deps and exports, importedBindings, staticImportedUrls) are not restored on seeded nodes.
  • Undeclared inputs: plugins that read env vars or files they don't declare.
  • No e2e yet. The first one to add is e2e/ssr-transform-cache.mjs: a server module derived from import.meta.env.VITE_CACHE_MARKER. Prove a warm cache hit, change only .env, and require the new value, with an unchanged control module so disabling the cache can't pass the test. It needs the Cloudflare runner deps (OJ_E2E_CF_DEPS). Then the stateful-virtual producer case and a resolver that throws once during restoration (restore must seed 0 and the next request must still resolve).

No overlap with #314, #315 or #316 (host liveness). #278 caps a different cache (moduleInfoCache). #312 changes the dep prebundle path. Both change plugin-host bytes, which invalidates existing snapshots when they land.

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