Skip to content

Add an opt-in cache for deterministic production frontend builds - #7455

Open
FarhanAliRaza wants to merge 19 commits into
mainfrom
farhan/frontend-build-cache
Open

FarhanAliRaza wants to merge 19 commits into
mainfrom
farhan/frontend-build-cache

Conversation

@FarhanAliRaza

@FarhanAliRaza FarhanAliRaza commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

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 Serialize production frontend work sharing one .web directory #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:

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/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

Two exports (or an export and a prod/preview start) in the same project
used to race on .web: the second replaced build/ and env.json while the
first was still compiling or zipping, so one of them shipped the other's
frontend. Hold an exclusive, reentrant file lock on
.web/.reflex-build.lock from compilation through ZIP creation, release it
before serving, and never create .web for a backend-only export that has
nothing to protect.

Split out of #7054 (lock only; the opt-in build cache that needs it follows
separately).

Claude-Session: https://claude.ai/code/session_018nWB9r2HcC5tkT72UxWgGj
With REFLEX_FRONTEND_BUILD_CACHE=true, a production build fingerprints its
inputs (generated frontend sources, assets, env.json, lockfiles, the
effective build environment, runtime binaries, and installed-dependency
metadata) and, on a match, restores the pristine Vite output instead of
running it again. Post-build plugins, fallback generation, compression, and
frontend-path processing still run on every build. Snapshots are verified
before restore and published only after a complete successful build; a
disabled cache discards any snapshot so a fresh build is guaranteed.

Disabled by default, macOS/Linux only. Split out of #7054; depends on the
workspace lock from the previous commit.

Claude-Session: https://claude.ai/code/session_018nWB9r2HcC5tkT72UxWgGj
@FarhanAliRaza
FarhanAliRaza requested review from a team and Alek99 as code owners October 6, 2026 18:20
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-06T18:25:40.006786Z 9d997ab PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 7 files

Reply with feedback, questions, or to request a fix.

Turn on auto-fix | Re-trigger cubic

Comment thread tests/units/utils/test_build_cache.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9d997ab081

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread reflex/utils/build_cache.py
@codspeed

codspeed Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

⚠️ 10 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 148 untouched benchmarks


Comparing farhan/frontend-build-cache (d181a8f) with main (d3f4ab1)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The reviewed changes appear safe to merge.

Summary

Adds an opt-in cache for deterministic production frontend builds. Cache hits restore pristine output before post-build work runs again.

  • Production frontend builds can reuse a verified local Vite result.

Reviews (9) · Last reviewed commit: "Discard the build snapshot when caching ..." · Reviewed by Greptile

Comment thread reflex/utils/build_cache.py
claude added 4 commits October 6, 2026 19:47
A cached production build that was killed after capturing its pristine
snapshot but before publishing it left reflex.build-cache/pending-* behind.
Deploy timeouts, CI cancellation and the OOM killer all do this, because the
production build installs no SIGTERM handler and the cleanup in `finally`
never runs. Nothing removed such a snapshot while the cache stayed enabled,
so every kill leaked one full copy of .web/build.

Capture now removes any other pending snapshot before it creates its own. It
runs under the exclusive build lock that every snapshot writer holds, so no
live build can own one. Removal is best-effort, like close(), so a leftover
that cannot be deleted never disables caching.

Claude-Session: https://claude.ai/code/session_01PXcFKAgnzhgdnSvhuCmxWE
The build cache reads environment.REFLEX_FRONTEND_BUILD_CACHE, which this
branch adds to reflex-base, so reflex now needs an unreleased reflex-base
change. Following the sibling dependency floor rule in AGENTS.md, raise the
floor to the .dev0 of reflex-base's next version, 0.9.13.dev0, which
excludes reflex-base 0.9.12 and earlier.

Claude-Session: https://claude.ai/code/session_01PXcFKAgnzhgdnSvhuCmxWE
Brings in #7449, which drops Python 3.10 and moves ruff to py311.

Claude-Session: https://claude.ai/code/session_01PXcFKAgnzhgdnSvhuCmxWE
PERF203 only applies before Python 3.11, where try/except in a loop costs
per iteration. With ruff targeting py311 since #7449 the directive is
unused, which RUF100 rejects.

Claude-Session: https://claude.ai/code/session_01PXcFKAgnzhgdnSvhuCmxWE

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Turn on auto-fix | Re-trigger cubic

Comment thread reflex/utils/build_cache.py Outdated
The cleanup of snapshots left by killed builds ran in capture(), which a
cache hit skips, so a leftover outlived every hit until the next miss.
Constructing the cache handle runs under the build lock on every cached
build, so the cleanup moves there.

Claude-Session: https://claude.ai/code/session_01PXcFKAgnzhgdnSvhuCmxWE

Copy link
Copy Markdown
Contributor Author

CodSpeed Performance Analysis (red on 01009c4) is not this PR's. It flags only test_route_argument_extraction (113.4 → 121.6 µs). That benchmark times reflex.route.get_route_args, and this PR changes nothing in routing. It only touches the build cache, build.py, one env var, docs and the dependency floor.

The benchmark has been jumping between about 113 µs and 121 µs on every run this hour, with CodSpeed warning that the runtime environments differed:

This run compares against the base's fast reading (113.4 µs). CodSpeed isn't a required check, and I can't re-run it from here.


Generated by Claude Code

@masenf masenf added documentation Improvements or additions to documentation perf Performance-improving changes labels Oct 19, 2026 — with ChatGPT Codex Connector
The production build lock had its own copy of the platform-specific
locking (flock / msvcrt) next to the sidecar-based json_file_lock, with
different Windows polling, a different failure mode and a lock file inside
.web. Keep one implementation: json_file_lock becomes file_lock, usable for
files and directories, and gains the reentrancy and fork handling the build
lock needed. Exports and production/preview startup now lock the .web
directory through it.

The sidecar lives in REFLEX_DIR, so locking never creates .web and the
lock inode survives .web being replaced. Backend-only exports therefore
always take the lock instead of skipping it when .web is absent, which
closes the race the review bots flagged. The lock is released explicitly
on Windows before the descriptor closes.

Claude-Session: https://claude.ai/code/session_017XAFCkYB3Z8w3S6pgJrLCh
# Conflicts:
#	pyproject.toml
The dedicated build lock module is gone; the cache now serializes through
the shared sidecar lock, which no longer lives in .web and so needs no
fingerprint exclusion. The symlinked-lock and lock-failure tests covered
behavior that lock no longer has.

Claude-Session: https://claude.ai/code/session_017XAFCkYB3Z8w3S6pgJrLCh
The backend ZIP walks the app root, and REFLEX_DIR sat inside it, so the
exporter archived its own held lock file. Windows refuses reads of a
byte-range-locked file, failing the zip with PermissionError.

Claude-Session: https://claude.ai/code/session_01PXcFKAgnzhgdnSvhuCmxWE
Comment thread reflex/utils/build_cache.py Outdated
Comment thread reflex/utils/build_cache.py Outdated
Base automatically changed from farhan/frontend-workspace-lock to main October 19, 2026 22:47
@masenf masenf added the on deck PRs lined up to review / merge next label Oct 19, 2026
# Conflicts:
#	docs/hosting/self-hosting.md
Hash relative POSIX strings from os.scandir instead of building and
relativizing a Path per entry, hash file content with hashlib.file_digest,
and reuse the path file_lock already resolved. The input fingerprint of
a 13.6k-entry .web drops from 308 ms to 73 ms.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 7 files

View guided diff | Turn on auto-fix | Re-trigger cubic

@masenf masenf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two P2 findings from the production-readiness audit of d3fe9ad: cache use must require confirmed lock ownership, and the documented custom-build contract needs to exclude untracked filesystem metadata.

Comment thread reflex/utils/build_cache.py
Comment thread docs/hosting/self-hosting.md Outdated

@masenf masenf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm still not 100% convinced that this is a feature we want. i'm struggling to see what users would use this

file_lock now yields a handle naming the resolved path and whether the OS
lock was acquired, directly or through an outer block in the same thread.
The build cache reads it and runs a plain build without restoring,
capturing, publishing or cleaning snapshots when the lock fell back to
unlocked, since every snapshot step assumes exclusive access. The docs
also state that source and asset fingerprints omit modification times
and other filesystem metadata.

Claude-Session: https://claude.ai/code/session_017XAFCkYB3Z8w3S6pgJrLCh
@FarhanAliRaza
FarhanAliRaza requested a review from masenf October 20, 2026 23:33
Comment thread tests/units/utils/test_build_cache.py

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread tests/units/utils/test_build_cache.py
Comment thread reflex/utils/build_cache.py Outdated
Comment thread reflex/utils/path_ops.py
Turning the cache off promises a fresh build and no leftover snapshot.
Deleting one without the workspace lock can only lose a snapshot, never
reuse a wrong one, so the discard no longer waits on the lock.

Claude-Session: https://claude.ai/code/session_017XAFCkYB3Z8w3S6pgJrLCh

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation on deck PRs lined up to review / merge next perf Performance-improving changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants