Skip to content

perf: disable React dev-build owner-stack capture in dev mode - #6905

Open
Alek99 wants to merge 7 commits into
mainfrom
alek/dev-owner-stacks
Open

perf: disable React dev-build owner-stack capture in dev mode#6905
Alek99 wants to merge 7 commits into
mainfrom
alek/dev-owner-stacks

Conversation

@Alek99

@Alek99 Alek99 commented Aug 18, 2026

Copy link
Copy Markdown
Member

Why dev mode feels slow to click around

Profiling real navigation clicks in a large Reflex app (the reflex.dev docs site, 1,023 pages) under reflex run vs --env prod, interleaved click-by-click on the same machine:

busy CPU per navigation click
reflex run (dev) 345.7 ms
--env prod 62.6 ms

71% of the dev CPU (246.7 ms) is a single function: React's development-build createElement. React 19's dev runtime constructs an Error("react-stack-top-frame") plus a console.createTask() for every element created, to capture "owner stacks" for DevTools. Two things make this brutal in practice:

  1. The cost scales with render depth. Error() walks the live JS stack: measured 2.1 µs/element at stack depth 2, but 12–17 µs at the depth React actually renders at. A docs page creates ~10,300 elements per client-side navigation → ~247 ms.
  2. Humans pay full price on every click. React budgets the capture to 10k elements, but react-dom resets the counter 1 s after the last render. Tight automated loops exhaust the budget once and look fast; a person clicking at a normal pace gets a fresh budget — and the full cost — every single click. This is exactly the "dev feels sluggish but benchmarks look fine" signature.

(The "outdated JSX transform" warning is a red herring here — the modern jsx/jsxs runtime executes the identical per-element capture.)

The fix

Emit a small dev-only snippet in the generated context.js that pins React's budget counter past its cap, so element creation takes the runtime's own cheap branch (unknownOwnerDebugStack). A pinned accessor is required — a plain write would be undone by react-dom's once-per-second reset (this is also why naive attempts to disable it appear to do nothing).

Everything else about the dev build is unchanged: dev warnings, component stacks in errors, StrictMode behavior, HMR/Fast Refresh (all re-verified against a live app, including in-place HMR). The trade-off: elements carry React's shared unknown-owner placeholder, so the public React.captureOwnerStack() returns no owner frames in dev — affecting React DevTools' owner-stack view and anything built on that API, such as custom error overlays and the onCaughtError/onUncaughtError root handlers (verified against the pinned React 19.2.8). REFLEX_REACT_OWNER_STACKS=1 restores full owner stacks; the guard is browser-only, so SSR diagnostics are untouched. The snippet is defensive (existence-checked, try/catch) so a future React that drops the internal is a no-op, and it is not emitted in prod builds at all.

Measured result

Interleaved A/B on one server boot (docs app, human-paced navigation clicks, CDP CPU profiles, median of n=10 per arm):

busy CPU / nav createElement self time settle time
unpinned (today's dev) 353.1 ms 250.3 ms 348.4 ms
pinned (this PR) 82.8 ms 1.0 ms 81.8 ms

−270 ms per click (−77%), taking dev from 5.6× prod to ~1.3× prod. Verified no console errors, navigation, state events, and hot reload working on both a minimal app and the docs app; full unit suite passes (7,460).

Review in cubic

React 19's development runtime constructs an Error() plus a
console.createTask() for every element it creates to capture "owner
stacks". The Error constructor walks the live JS stack, so the cost
grows with render depth: measured 2.1us per element at stack depth 2
but 12-17us at the depth React actually renders at. On a large page
(the reflex.dev docs app, ~10,300 elements per client-side navigation)
this is 247ms of main-thread CPU per navigation - 71% of all dev-mode
render CPU - and is the main reason dev mode feels slow to click
around compared to prod.

React budgets the capture to 10k elements, but react-dom resets the
counter one second after the last render, so a human clicking at a
normal pace pays the full cost on every click while tight automated
loops exhaust the budget once and look fast.

Pin the budget counter past its cap from the generated context.js (dev
mode only) so element creation takes the runtime's own cheap branch.
A pinned accessor is required - a plain write would be undone by
react-dom's once-per-second reset. All other dev-build behaviour
(warnings, component stacks in errors, Fast Refresh) is unchanged;
only the owner-stack detail in React DevTools is lost. Set
REFLEX_REACT_OWNER_STACKS=1 to restore it.

Interleaved A/B on the docs app, human-paced navigation clicks,
median of 10 per arm on one server boot:

    unpinned:  353.1ms busy CPU/nav  (createElement 250.3ms)
    pinned:     82.8ms busy CPU/nav  (createElement   1.0ms)

which closes most of the gap to the production build (62.6ms) that
was previously a 5.6x difference.
@Alek99
Alek99 requested a review from a team as a code owner August 18, 2026 00:13
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@codspeed-hq

codspeed-hq Bot commented Aug 18, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing alek/dev-owner-stacks (aecdaa6) with main (7ada71b)

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@greptile-apps

greptile-apps Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR reduces development-mode rendering overhead by disabling React owner-stack capture by default while preserving an environment-variable escape hatch. It also makes rx.script head updates synchronous to avoid losing script tags around hydration.

  • Generates a browser-only React-internals pin in development builds.
  • Adds REFLEX_REACT_OWNER_STACKS to restore owner-stack diagnostics.
  • Configures script-backed Helmet updates with defer=False.
  • Adds compiler and component regression tests plus release notes.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/reflex-base/src/reflex_base/compiler/templates.py Generates the guarded owner-stack counter pin; the previously requested key extraction is correctly applied to both executable accesses.
reflex/compiler/compiler.py Enables the generated pin only in development when the owner-stack escape hatch is disabled.
packages/reflex-base/src/reflex_base/environment.py Defines the typed environment toggle that restores React owner-stack capture.
packages/reflex-components-core/src/reflex_components_core/base/script.py Disables Helmet's deferred update scheduling for script components.
packages/reflex-components-core/src/reflex_components_core/core/helmet.py Exposes Helmet's defer property through the component model.
tests/units/compiler/test_compiler.py Verifies conditional emission, browser guarding, imports, and documentation of the owner-stack pin.
tests/units/components/base/test_script.py Verifies that script components render Helmet with synchronous flushing enabled.

Reviews (5): Last reviewed commit: "fix: flush rx.script head updates synchr..." | Re-trigger Greptile

Comment thread packages/reflex-base/src/reflex_base/compiler/templates.py Outdated
Alek99 added 3 commits August 17, 2026 17:19
Review feedback on two points, both correct:

- The snippet mutated shared React internals unconditionally, but this
  module is also evaluated by the server renderer, so SSR owner-stack
  diagnostics were disabled in the same process. Guard it on a
  browser check so it only runs client-side.
- The trade-off is broader than "React DevTools". Pinning the counter
  makes elements carry React's shared unknown-owner placeholder, so the
  public React.captureOwnerStack() returns no owner frames -- which also
  affects custom error overlays and the onCaughtError / onUncaughtError
  root handlers. Verified against the pinned React 19.2.8. Say so in the
  generated code and the news entry instead of understating it.

Adds a compiler test covering both directions: the pin is absent when
owner stacks are requested or in prod, and when present it is wrapped by
the browser guard and carries the escape hatch and trade-off in the
emitted comment.
@Alek99

Alek99 commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Side-by-side: navigating a content-heavy app in dev mode

Recorded a click-through of the same app with and without this change. Left pane is dev mode as it ships today, right pane is with the pin. Same app, same four navigation clicks, same machine; the header bar in each pane shows that click's render time, measured in-page from the click to the last DOM mutation.

click before after
1 50 ms 18 ms
2 46 ms 17 ms
3 64 ms 18 ms
4 52 ms 17 ms

Method notes, since a side-by-side is easy to make lie:

  • The two arms were recorded separately, not simultaneously — running both at once would have had them competing for CPU and inflated both sides. The recordings are aligned on the first click afterwards, so the same wall-clock moment appears in both panes.
  • Both arms run against the same server build; the "before" arm restores React's default counter in-page, so the only difference between the panes is the owner-stack capture itself.
  • Each click is preceded by ~1.4 s of idle, because react-dom resets the 10k-element owner-stack budget one second after the last render. That is the human-paced case this PR is about: a tight automated loop exhausts the budget once and shows almost no difference, which is why this never showed up in benchmarks.
  • This is a synthetic content-heavy app (~5k elements per navigation), not the docs site, so the absolute numbers are smaller than the ones in the description. The effect size is the same: ~75% less main-thread CPU per click. On the reflex.dev docs app the same measurement is 353 ms → 83 ms.

@Alek99
Alek99 requested a review from FarhanAliRaza August 18, 2026 01:22

@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 wonder if it makes more sense to switch the flexgen sandbox to use the new --env preview mode that basically makes an unoptimized prod build. would probably need to benchmark it to see if the compile time is acceptable.

this feels pretty hacky, but it does make a noticable difference and has a chicken switch, so it's okay with me.

Comment thread packages/reflex-base/src/reflex_base/compiler/templates.py Outdated
Alek99 added 2 commits August 17, 2026 22:31
Per review: the generated context.js does not need the full essay; keep
the one-line what/cost/escape-hatch summary and link PR #6905 for the
rest. Re-verified the pin still engages at runtime after the trim.
Root-causes the flaky "scripts not loaded" failures in
tests/integration/test_call_script.py (also seen on main at 7e57be7).

rx.script wraps its tag in react-helmet, whose client-side flush is
deferred and batched via requestAnimationFrame by default. That flush
can be lost around hydration, leaving the page's script tags out of the
document entirely: in the failing state the DOM contains no helmet-
attributed tags at all and both the inline and external script globals
are undefined, while the app is otherwise hydrated and interactive.

The race predates this PR - it reproduces locally on the base commit's
code path (pin disabled: 1/5 runs) - but the owner-stack pin makes dev
renders 3-4x faster, which shifts the timing and roughly triples the
incidence (pin enabled: 3/5 runs). Passing defer={false} on the Helmet
wrapper makes the head update part of the synchronous commit:

    deferred helmet + pin:   3/5 runs failed
    deferred helmet, no pin: 1/5 runs failed
    defer=false + pin:       5/5 runs passed

Scope is rx.script's Helmet wrapper only; other head usage keeps the
default batching. Adds a unit test pinning the rendered defer prop.
@Alek99

Alek99 commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

The integration-app-harness "scripts not loaded" failures were real — root-caused and fixed in aecdaa62

I stopped treating this as a rerun-able flake when it failed twice in a row here. Summary of the investigation:

Symptom (captured from a live failing state): the app hydrates and is interactive, but the DOM contains no rx.script tags at all — no helmet-attributed tags, both the inline and external script globals undefined. So it was never a fetch/timing problem with the external file; the tags were simply never injected.

Cause: rx.script wraps its tag in react-helmet, whose client-side flush is deferred and batched via requestAnimationFrame by default. That flush can be lost around hydration, dropping the page's head updates entirely.

Attribution (interleaved runs of test_call_script[external] on one machine):

configuration failures
deferred helmet, pin disabled (= main's code path) 1/5
deferred helmet, pin enabled 3/5
defer={false} helmet, pin enabled 0/5

So the race predates this PR — it also failed on main at 7e57be74 in CI — but this PR's faster dev renders shift the timing and make it fire noticeably more often. That's an interaction I didn't catch in the earlier regression pass, and it's why I'm folding the fix in here rather than leaving the amplification behind.

Fix: pass defer={false} on rx.script's Helmet wrapper only, so the head update is part of the synchronous commit. Other Helmet usage keeps the default batching. Unit test pins the rendered prop; news entries added for the bugfix.

@Alek99
Alek99 requested a review from masenf August 18, 2026 18:02
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.

3 participants