Add an opt-in cache for deterministic production frontend builds
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.jsonminus private timestamps, the effective build environment (everythingfrontend_env()passes to the subprocess exceptREFLEX_LOGLEVELandSHLVL), runtime binaries, Reflex version, and installed-dependency file metadata (dev/inode/size/mtime/ctime; content is not hashed fornode_modules). - Post-build plugins, fallback generation, compression, and
frontend_pathprocessing 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=falsediscards the snapshot so a fresh build is guaranteed. Symlinks leaving.web, or into ignored caches, bypass the cache. - The build takes the shared
.weblock throughpath_ops.file_lockfrom #7453, so reload builds and exports serialize with each other. - Docs in
docs/hosting/self-hosting.md; news fragments forreflexandreflex-base.
Measurement
Unchanged app, repeat reflex export, bun:
| main | lock only (#7453) | cache off | cache on (hit) | |
|---|---|---|---|---|
| total export | 6.90 s | 6.84 s | 7.35 s | 1.90 s (−73%) |
| build phase | 6.14 s | — | 6.55 s | 1.12 s |
| Vite ran | yes | yes | yes | no |
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/unitssuite passes locally except the 14reflex_bench/drivers/test_browser.pycases that need a Playwright browser launch, which fail identically on untouchedmainin this sandbox -
ruff check,ruff format --check,codespell, andpyright reflex testspass locally - News fragment added for every package touched
https://claude.ai/code/session_018nWB9r2HcC5tkT72UxWgGj
https://claude.ai/code/session_017XAFCkYB3Z8w3S6pgJrLCh