Add an opt-in cache for deterministic production frontend builds

FA
11h ago
+1218-20

Split out of #7054. Built on #7453 (the workspace lock), which has merged; this PR now targets main and its diff is the cache alone. This is the big one and the one @masenf was nervous about, so the numbers below are the case for it.

Changes

REFLEX_FRONTEND_BUILD_CACHE=true (default off, macOS/Linux only) makes build.build() fingerprint its inputs and, on a match, copy the previous pristine Vite output back into .web/build instead of running Vite:

  • Inputs: generated frontend sources, assets, env.json, lockfiles, reflex.json minus private timestamps, the effective build environment (everything frontend_env() passes to the subprocess except REFLEX_LOGLEVEL and SHLVL), runtime binaries, Reflex version, and installed-dependency file metadata (dev/inode/size/mtime/ctime; content is not hashed for node_modules).
  • Post-build plugins, fallback generation, compression, and frontend_path processing still run on every build, on the restored output.
  • Snapshots are content-verified before restore, captured before post-build hooks mutate the output, and published only after the whole build succeeded and the inputs are still unchanged. REFLEX_FRONTEND_BUILD_CACHE=false discards the snapshot so a fresh build is guaranteed. Symlinks leaving .web, or into ignored caches, bypass the cache.
  • The build takes the shared .web lock through path_ops.file_lock from #7453, so reload builds and exports serialize with each other.
  • Docs in docs/hosting/self-hosting.md; news fragments for reflex and reflex-base.

Measurement

Unchanged app, repeat reflex export, bun:

mainlock only (#7453)cache offcache on (hit)
total export6.90 s6.84 s7.35 s1.90 s (−73%)
build phase6.14 s—6.55 s1.12 s
Vite ranyesyesyesno

Same with npm: 1.63 s total / 0.88 s build phase on a hit, versus 7.6 s / 6.9 s when Vite runs.

A cache miss (deploy URL changed, so env.json differs) costs the fingerprinting plus a snapshot copy on top of a normal build: 9.22 s total versus 7.35 s with the cache off (+1.9 s, two input fingerprints plus a snapshot copy of .web/build), so the cache only pays for itself when at least every third build is a hit.

What a user gets: the Vite step is the whole export on a repeat deploy, so a hit removes roughly 85% of it. What it costs: ~450 lines in build_cache.py, a new env var to document, and the lock in #7453. It only helps repeat deploys of an unchanged frontend on one machine, and it must stay off for builds that read the clock, the network, or files outside .web.

Measurement setup

Five-page app from the blank template (Radix Themes + Tailwind v4 plugins), Linux x86_64, 4 vCPU, Python 3.14, Node 22.22, npm 10.9, bun 1.4.2. Each run is a fresh interpreter calling reflex.utils.export.export() the same way reflex export does; durations are the phase timings the export already reports to telemetry (compile includes the dependency install). Medians of 3 runs unless noted. Vite dominates every export (~6.5 s); these numbers isolate the part this PR touches.

Type of change

  • New feature (non-breaking change which adds functionality)
  • This change requires a documentation update

Checklist

  • Tests added for the change; the full tests/units suite passes locally except the 14 reflex_bench/drivers/test_browser.py cases that need a Playwright browser launch, which fail identically on untouched main in this sandbox
  • ruff check, ruff format --check, codespell, and pyright reflex tests pass locally
  • News fragment added for every package touched

https://claude.ai/code/session_018nWB9r2HcC5tkT72UxWgGj
https://claude.ai/code/session_017XAFCkYB3Z8w3S6pgJrLCh