Performance
phlex-reactive aims to be fast in the places that run on every interaction — the re-render, the token signing, the param coercion, and the client hot path.
What we optimize#
phlex-reactive aims to be fast in the places that run on every interaction: the component re-render, the identity-token signing, the param coercion, and the client request hot path. This page documents how those paths are kept fast, how to measure them yourself, and how performance is part of every change.
The honest summary: the re-render is the part we control and the part we optimized hardest, but a full HTTP action is dominated by the Rails middleware stack and (for record-backed components) the database — so the render wins matter most for broadcasts, which have no HTTP overhead to amortize against. To be precise about the fan-out: a broadcast_to call renders the component once and hands the finished HTML to the transport, so every subscriber of that stream shares one payload (the per-subscriber cost is transport-side, not a render). The render cost multiplies per call: pushing one change to K different stream keys with a hand-written loop over broadcast_to is K builds + K renders + K token signings of byte-identical HTML. For that same-payload, many-key fan-out, passing each: (issue #119) renders once and loops only the cheap channel call — measured at ~9.5× throughput and ~8× fewer allocations at K=10 (see the fan-out table below). Per-viewer content (visible_to:-style rendering, DIFFERENT HTML per viewer) stays the irreducible render-per-viewer case. Measure before you optimize; the harness below exists so you never have to guess.
The hot paths#
render_component— every action re-render and every broadcast render. Renders through phlex-rails' lightweight#render_inagainst a memoized off-request view context, instead ofActionController.renderer.render. ~1.9× faster, ~half the allocations, byte-identical HTML.- view context /
TagBuilder— built once per thread per component class and reused. The context is request-bound (Phlex::Reactive.request_bound_view_context) so request-dependent helpers —form_authenticity_token,protect_against_forgery?, host-aware*_url— keep working during a re-render/broadcast. The request setup happens only on the build, not per render, and it is reset on Rails code reload. reactive_token— every render (it is inreactive_attrs). Ivar symbols (:@count) and state string-keys are precomputed per class, so signing no longer allocates a Symbol/String per state key. The HMAC itself dominates and is unavoidable.on(:action)— every trigger rendered. The no-params case (the common one) skips re-serializing{}to JSON.coerce_params— every action with a param schema. Three wins on this path: the schema is compiled once at declaration (ParamSchema.compile, fromaction :name, params:), so a click never re-walks or re-validates it; the per-request work is bracket-key expansion of Rails-form field names (invoice[items][0][qty]→ nested hash) using a frozenBRACKET_SEGMENTregex (no per-key recompile); and each scalar dispatches through the param-type registry, which is frozen after boot (freeze_param_types!) so lookups hit a stable hash with no allocation.- client meta lookups — every dispatch. The page-stable action path is resolved once per controller; CSRF + pgbus connection id stay live (they can rotate).
Observability (ActiveSupport::Notifications)#
Phlex::Reactive.apm = :appsignal), error reporting, and the full event reference now live on the Observability & APM page. This section keeps the performance-relevant note: an unsubscribed instrument is cheap, so the hot paths carry it unconditionally.The hot paths emit ActiveSupport::Notifications events so an APM (AppSignal, Datadog, Skylight) sees reactive traffic at the component level — which component/action a slow request was, how long a render took, and broadcast fan-out. Three events, all in the phlex_reactive namespace:
action.phlex_reactive— one per request. Payload:component,action,outcome(ok/denied_undeclared/invalid_token/not_found/unauthorized/unverified).render.phlex_reactive— around each component render. Payload:component,bytesize.broadcast.phlex_reactive— around eachbroadcast_tocall (fires on Action Cable AND pgbus). Payload:component,stream_action,streamables(the key count).defer.phlex_reactive— one per deferred render (thereply.deferendpoint). Payload:component,outcome(ok/no_content/invalid_token/not_found/unauthorized).
Payloads carry names, the outcome, and sizes only — never the token, the params, or component state, so an event can never leak a secret. An invalid_token event has no trusted component name (the token did not verify), so it is omitted.
Subscribe from an initializer exactly as you would for any Rails event:
ActiveSupport::Notifications.subscribe('action.phlex_reactive') do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
# event.payload => { component:, action:, outcome: }
# event.duration => ms
MyAPM.record("reactive.#{event.payload[:outcome]}", event.duration,
component: event.payload[:component], action: event.payload[:action])
endTo watch reactive traffic in your own log without an APM, flip on the bundled LogSubscriber (default off). It logs one compact line per event at DEBUG:
Phlex::Reactive.log_events = true
# [reactive] Counter#increment ok (3.1ms)
# [reactive] Counter#drop_table denied_undeclared (0.2ms)
# [reactive] render Counter 512B (0.9ms)
# [reactive] broadcast replace Counter →2 (1.4ms)
# [reactive] defer SlowTotals ok (18.2ms)Client debug mode (devtools-lite)
The LogSubscriber above is the server lens. The client lens is console.error on a failure plus the lifecycle events — but on the successful-but-wrong path (which streams arrived? did a token refresh come?) there was nothing to see. Phlex::Reactive.debug fills that gap: turn it on and every reactive root carries data-reactive-debug="true", so the generic controller console.groups every dispatch in the browser.
Phlex::Reactive.debug = Rails.env.development?▼ reactive #todo_42 rename → 200 (48ms)
params: [title] + collected: [title]
encoding: json
streams: replace → #todo_42
token: refreshed ✓Rails.env.development?.Why a param silently vanished (verbose_errors)
The LogSubscriber tells you a request happened; Phlex::Reactive.verbose_errors tells you why an action got its keyword default instead of your value — the drop-don't-fabricate contract means a param that fails coercion or isn't in the schema is dropped without an error. When on, param coercion warn-logs every dropped key with its bracketed path and reason (undeclared — not in the schema, the invoice[date]-vs-flat-schema footgun; or uncoercible — present but wouldn't cast), and an endpoint failure carries a plain-text diagnostic body. It defaults to Rails.env.local? (development and test), so production stays opaque unless you opt in.
Phlex::Reactive.verbose_errors = Rails.env.local? # the default; set = false to silence
# [phlex-reactive] dropped param invoice[date] (undeclared)
# [phlex-reactive] dropped param invoice_items_attributes[0][qty] (uncoercible)coerce passes a nil collector and every diagnostic branch early-returns, so the drop-path stays zero-cost in production. The bracketed-path logging only runs when you flip the flag on.Measuring#
Everything is driven from rake:
rake bench # the micro-benchmark suite (alias for bench:micro)
rake bench:micro # render, reactive_token, verify/sign, coerce_params — isolates each method
rake bench:request # end-to-end POST /reactive/actions through the full Rack stack
rake bench:client # the client hot paths (extractToken, collectFields, recompute, runOps, connect) via bun
rake bench:one[render] # a single micro-bench by name- Micro-benches (
benchmark/micro/*.rb) isolate one method with benchmark-ips (throughput) and memory_profiler (allocations). They boot the dummy app so they exercise the real render path. - The request bench (
benchmark/request/derailed.rb) drives the dummy app's full Rack stack (middleware → router → controller → token verify → action → re-render → turbo-stream) viaRack::MockRequest— the same call-the-app primitive derailed_benchmarks uses — so the numbers reflect production action latency. - The client bench (
benchmark/client/, run withbun) covers the JS dispatch hot path —#extractToken,#collectFields,recompute— with mitata + happy-dom. It drives the controller's PUBLIC surface only (no test-only exports on the shipped controller), so nothing underapp/javascript/changes. Read the framing below before comparing numbers across machines.
Reading the output
benchmark-ips reports i/s (iterations per second — higher is better) and μs/i (microseconds per call — lower is better). memory_profiler reports objects/bytes allocated (transient GC pressure) and retained (objects that survive — a steady climb here is a leak). For a re-render, retained should be 0; a non-zero retained count per render is the smell the view-context memoization fixed.
Measure BEFORE you change#
The first rule: capture the baseline before touching code, or you cannot claim a delta. The cleanest way for a gem is an isolated worktree so main and your branch run the same script on the same machine:
git worktree add --detach /tmp/baseline main
# Copy the harness AND the Rakefile/Gemfile so `rake bench` exists in the
# pristine tree (main predates the bench task).
cp -r benchmark /tmp/baseline/ && cp Gemfile Rakefile /tmp/baseline/
(cd /tmp/baseline && bundle install && RAILS_ENV=test bundle exec rake bench:micro) > /tmp/before.txt
RAILS_ENV=test bundle exec rake bench:micro > /tmp/after.txt # your branch
diff /tmp/before.txt /tmp/after.txt
git worktree remove --force /tmp/baselineIf the branch added a bench that calls a method not on main (e.g. a new reset_*!), write a baseline-safe script that only calls methods present on main and run that in both trees.
Toggling a single optimization in place (e.g. PHLEX_REACTIVE_NO_CACHE=1 ruby benchmark/micro/render.rb) is an even cleaner apples-to-apples for one change — it removes machine-to-machine and worktree variance.
Representative numbers#
Measured on Ruby 3.4 +YJIT, Apple Silicon, the dummy app — the before column is pristine main run in an isolated worktree with the same script as the after column, so it is a true same-machine before/after. Your absolute numbers will differ; the ratios are the point.
render_componentthroughput: 6.99k i/s (143 μs) → 14.1k i/s (71 μs) — 2.0× faster.render_componentallocations: 212 obj → 99 obj — −53%.to_stream_replacethroughput: 4.60k i/s (217 μs) → 8.00k i/s (125 μs) — 1.7× faster.to_stream_replaceallocations: 331 obj → 191 obj — −42%.reactive_token(state) allocations: 14 obj → 11 obj — −21%.on(:action)(no params) allocations: 6 obj → 5 obj — −17%.
Multi-key broadcast fan-out (issue #119)
The broadcast bench doubles the transport out to a no-op, so what is measured is the server-side build + render + identity-HMAC cost a broadcast pays before the wire. Fanning one component out to K=10 stream keys, a hand-written loop over broadcast_to(key, replace:) vs broadcast_to(each: keys, replace:):
- Throughput: 2.88k i/s (347 μs) → 27.3k i/s (37 μs) — 9.5× faster (and within ~4% of a single 1-key broadcast: K renders + K HMACs collapse to 1 + 1).
- Allocations: 1250 obj / 186 KB → 151 obj / 22 KB — −88% objects, 0 retained.
model_param_name(the measure-first candidate): 815k i/s, 8 obj/call — immaterial next to the ~37 μs build, so it was measured and left alone (no memoization).
Full-stack request numbers
No clean before/after — these are reference figures for production action shape:
POST /reactive/actions(state-backed): ~1.6k req/s (636 μs), 828 obj/req.POST /reactive/actions(record-backed, +DB): ~1.1k req/s (895 μs), 1316 obj/req.coerce_params(2-row nested form): ~37k i/s (27 μs), 218 obj/call.
What these tell you
- The render path got ~2× faster and halved its allocations — but at the full request level that delta is within noise, because routing + middleware + token verify + transaction dominate. Do not expect a render optimization to move request throughput; expect it to move broadcast-heavy code — one render per broadcast call, so K stream keys (or per-viewer rendering) = K renders with no HTTP — and to cut GC pressure under load.
- Record-backed actions are ~1.4× slower than state-backed — that is the GlobalID re-find + DB write, which is the security model working as designed (state lives in the database), not overhead to remove.
- Signing a record-backed token no longer builds a GlobalID URI. For a record whose
to_gidis GlobalID's own and whose id is an Integer, the gid string is built directly (byte-identical, pinned by a spec); anything else still callsto_gid. In-place A/B: the record-backed token went from ~11.4 µs and 54 objects to ~5.2 µs and 20, so a 100-row record-backed list signs its rows in about half the time.
Verify & sign numbers#
The two MessageVerifier HMAC paths run on every interaction: Phlex::Reactive.verify before anything else on every request (ActionsController#verified_payload is the first line — a garbage-token flood pays it too), and Phlex::Reactive.sign once per rendered component (reactive_token — so N times for an N-row reactive collection). benchmark/micro/verify.rb isolates both. Ruby 3.4 +YJIT, Apple Silicon, the dummy app:
verify — four cost classes
- garbage (
"x" * 64, the flood cost): ~1.2M i/s (0.8 μs), 4 obj/call. A malformed token bails at format parsing before the HMAC ever runs, so a bad-token flood is the cheapest path — nothing to optimize. - tampered (valid shape, corrupted signature): ~203k i/s (4.9 μs), 9 obj/call — 6× slower than garbage. A near-miss forgery pays the full constant-time HMAC compare of the whole digest; garbage never gets there. That gap is the security model working as designed (no early-exit oracle on the signature).
- valid, state-backed (
{c, s}): ~136k i/s (7.4 μs), 20 obj/call. - valid, record-backed (
{c, gid}): ~147k i/s (6.8 μs), 20 obj/call. (The GlobalID re-find + DB load happens downstream, in the action — not in verify.)
sign — per component, and at collection scale
sign×1 (state or record): ~155k i/s (6.5 μs), 12 obj/call.sign×100 — the collection cost (rendering a 100-row reactive collection signs once per row): ~1.6k i/s (610 μs), 1200 obj/call. It scales linearly, as expected — the HMAC is unavoidable and there is no shared work across rows to hoist.
Digest / serializer: measured, and the question is closed
The default verifier uses whatever Rails.application.message_verifier hands back — SHA1 with the JSON-with-Marshal-fallback serializer. The bench compares that against explicitly-built verifiers on the same secret with SHA256 and/or a strict JSON serializer, for both verify and sign. Every variant falls within measurement noise — the differences reorder run-to-run and benchmark-ips reports them as "difference falls within error". The HMAC over a ~120-byte payload is not where the time goes, and the serializer choice does not move it either.
Phlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(secret, digest: "SHA256") — but the measurement says you will not need it. The question is closed until the numbers say otherwise.Deferred segments (reply.defer) — a latency shape, not a throughput win#
reply.defer (#165) moves an expensive reply segment OFF the actor's critical path. Be precise about what that buys: the actor's reply latency improves by roughly the deferred segment's render cost, while time-to-full-content gets slightly WORSE (one extra hop). The cost moves; it never disappears. The request-level A/B spec (spec/requests/deferred_latency_spec.rb) pins exactly that: with a 120 ms segment, the sync reply pays ≥ 120 ms, the deferred reply dodges it, and the deferred fetch pays it instead.
The machinery itself stays off the hot paths — the same-machine, same-checkout before/after against main held to_stream_replace at 14.4k → 14.2k i/s (within ±3.7% noise) and the identity token at 199.9k → 196.7k i/s (state-backed), allocations byte-identical. Per deferred segment the reply pays one purpose-scoped sign_defer (~120k i/s) and the directive build (~11 μs, 0 retained); the defer endpoint pays one verify_defer (~99k i/s). benchmark/micro/defer_token.rb isolates all three.
Cached fragments (reactive_lazy cache:) — the request that never happens#
A lazy fragment that is the same for a viewer on every page was fetched and rendered once per page view. reactive_lazy cache: { max_age: } (#277) makes that render a privately cacheable GET, so a repeat view costs neither a network request nor the render. This is a request-level win, and the only one on this page that removes server work outright rather than moving or shrinking it — but only for the hits: the first view of each (viewer, version) still pays the full render.
| Page view | Without cache: | With cache: + reactive_cache_viewer |
|---|---|---|
| first | 1 request, 1 render | 1 request, 1 render |
later, within max_age | 1 request, 1 render each | 0 requests, 0 renders |
after max_age | 1 request, 1 render | 1 conditional request, 1 render, 304 when the body is unchanged |
Without reactive_cache_viewer the reply varies on the cookie, and Rails' cookie session store changes the cookie on every response — so the reuse is limited to one page (a refresh morph). Declare the viewer to reuse across page views. See Deferred rendering.
The shell costs a few microseconds more than the one it replaces: it signs a fragment id where the plain shell signs a defer token, plus one keyed digest for a declared viewer; an on: + cache: shell signs the identity token for its trigger as well. Once per page render, same machine, benchmark/micro/fragment.rb:
| Per call | Throughput | Allocations |
|---|---|---|
sign_fragment | ~195k i/s (5.1 μs) | 13 objects |
verify_fragment (once per origin hit) | ~145k i/s (6.9 μs) | 24 objects |
sign_defer (the plain shell's token) | ~133k i/s (7.5 μs) | 30 objects |
| plain lazy shell render | ~83k i/s (12.0 μs) | 77 objects |
| cached shell render (URL + viewer) | ~63k i/s (16.0 μs) | 50 objects |
on: shell render | ~71k i/s (14.2 μs) | 40 objects |
on: + cache: shell render | ~40k i/s (24.8 μs) | 64 objects |
So cache: adds roughly 4 μs to a plain lazy shell and 10 μs to an on: shell — against the request and render it removes on every later view. The existing shells are untouched: against main, the plain lazy shell and the on: shell allocate the same 77 and 40 objects, and reactive_token the same 18.
Client dispatch numbers#
The client hot path — the JS that runs in the browser on every click and keystroke — is benched off-browser with mitata + happy-dom (rake bench:client). The benched paths are #extractToken (regex-reading the next signed token out of the turbo-stream response body), #collectFields (the one walk that auto-collects a root's named inputs into the action params, scoped past nested reactive roots), and recompute (the client-side data-binding compute), runOps (a client-only binding record), and connect() + disconnect() over 2,000 roots (a lifecycle-cycle cost, not connect alone: what every root pays once, and the one place a feature module adds work per root, issue #275). All are driven through the controller's public surface (dispatch() / recompute() / connect()) — no test-only export is added to the shipped controller.
How to read these — two different kinds of number
These are not all the same currency. Read them by what engine produced them:
- engine-faithful —
#extractTokenis a pure regex pass over a string. bun runs on JavaScriptCore, the same engine class a browser uses, so the regex numbers approximate real browser cost (not identical, but the right order of magnitude). - engine-relative —
#collectFieldsandrecomputewalk a real DOM, provided by happy-dom (a JS DOM implementation, NOT a real browser's C++ DOM). happy-dom's node/query costs differ from Blink/WebKit in absolute terms, so treat these as a same-machine before/after baseline — valid for measuring whether a change made THIS path faster or slower, not as an absolute "microseconds in Chrome" figure.
Representative baselines
Measured on bun 1.3 (JavaScriptCore), Apple M2 Max. Your absolute numbers will differ; capture your own before/after on one machine.
- engine-faithful.
#extractTokenover a ~2KB single-component response: ~4.4 µs. Over a ~500KB 200-row reactive collection (the worst realistic scan — every row carries its own token, the container's fresh token rides last): ~9 µs. For comparison, a fullDOMParserparse of that same 500KB body is ~4.3 ms — ~450× slower: the targeted regex is why token extraction never parses the body into a document. The two per-id regexes are memoized on the stable root id (issue #118) — compiled once, reused across every response, rebuilt only if the id changes — but this was measured, not assumed: extractToken is already ~0.25% of theDOMParserceiling, so removing two regex allocations per call is a correctness/cleanliness win that sits below the timing floor of the dispatch-driven bench. Not worth further optimization. - engine-relative.
#collectFieldsvia a full dispatch over happy-dom: ~34 µs for a 5-field form, ~96 µs for a 60-field grid. Adding 2 nested reactive roots (the ownership filter, issue #15) adds ~6%. The ownership check is hoisted to once per dispatch (issue #117): with no nested reactive root — the common case — theclosest()scope check per field is skipped entirely.collectFieldsruns once per dispatch, so this is dominated by the dispatch overhead and the fast path does not move it out of noise. - engine-relative.
recomputeon a 30-input calculator (read every declared input, run the reducer, write one output): ~23 µs per keystroke over happy-dom, down from ~31 µs (~25%). This is where the issue #117 fast path pays off: the pre-#117 per-namequerySelectorAll+closest()walk ran per input AND per output on every keystroke (~60 DOM queries on a 30-field calculator); the hoisted ownership probe plus a first-winsbyNamememo collapses that to one query per distinct declared name, with the ownership decision made once. A per-keystroke (method-level) win, not a request-level one.
collectFields / recompute figures include the full dispatch() overhead around the walk (that's the price of benching through the public surface instead of a private export). They are a consistent baseline for a before/after, not the isolated cost of the walk alone.Connect and disconnect, in a real browser
rake bench:browser runs the shipped minified client with the real Stimulus in headless Chromium: the median of 15 insert+connect and disconnect runs for bare, dirty-guarded and persisted roots at 500, 2,000 and 4,000 roots, the cost of doubling N, and a heap soak (60 rounds of 500 roots mounted and unmounted, heap after a forced GC). Pass another build's vendor directory to compare two clients: bun run benchmark/browser/connect.mjs path/to/vendor.
Trust this one over the happy-dom connect bench. happy-dom showed a 5–10× connect regression after the early-trigger work (#273) that was mostly its own attribute and query cost (about 0.8 µs a root in Chromium), and it never showed the real problem: each warn_unsaved root added its own window listener pair, and each persisted root its own document listener. A browser scans a target's listeners on every add and remove, so connecting N such forms was quadratic. One shared listener per window or document now serves them all: 4,000 guarded roots connect in ~227 ms instead of ~306 ms, 4,000 persisted roots in ~230 ms instead of ~280 ms.
The bench also runs the same markup under a no-op Stimulus controller (the control rows) and prints the runtime's own cost per root as the difference: about 10–16 µs at connect and 1–3 µs at disconnect, flat across 500 to 4,000 roots. Doubling N still costs more than 2× in every row, the control included (~3×): that curve is Stimulus and Chromium, not the runtime. The heap stays flat.
Payload size (what the browser downloads)
The client runtime is auto-pinned and preloaded on every page, so its transfer size is a real cost. The authored source is comment-dense on purpose (the source IS the documentation, and the JS suite imports it), so the gem doesn't ship it to browsers — it ships a prebuilt minified twin of each module (rake build:js, bun) with a linked sourcemap. The engine pins the .min.js; devtools resolves the .map back to the readable source on demand.
reactive_controller(the default client: the runtime and every feature module in one file): 399 KB of source → 82 KB minified (−79%). Gzipped, the source would be 125 KB; the minified file is 24.9 KB (−80%).confirm.js+compute.js+confirm_predicate.js: 18 KB → 2.4 KB minified combined (the three override seams).
The bun minifier output is deterministic (byte-identical across bun patch releases), so the .min.js/.min.js.map are committed and shipped in the gem — consumers need no bun — and CI (rake build:js_check) rebuilds and fails if a source edit landed without a rebuild. The system suite runs the vendored minified build in a real browser under both Puma and Falcon, so the code that ships is the code that is proven.
Loading the client lazily#
An app that keeps light pages light loads its Stimulus controllers on demand (stimulus-loading, a preload: false pin). The reactive controller can load that way too, as long as phlex/reactive/early is imported eagerly (issue #273). It is under 1.5 KB gzipped (a test asserts it) and has no Stimulus import. Until a root connects, it queues the trigger events that reach it and stops their native default where the controller would; the controller replays them when it connects. Without it, a click in the load window is lost, so the only safe choice used to be loading the full client on every page that might contain a root.
Each root marks the moment with data-reactive-connected and a bubbling reactive:connect event. Queued triggers older than Phlex::Reactive.early_event_ttl_ms (10 s by default, read from <meta name="phlex-reactive-early-ttl">) are dropped rather than fired out of nowhere.
Dormant roots#
Loading the controller lazily only helps on pages with no reactive root. One root in a shared layout (a closed dialog, a collapsed panel, a menu that loads its items on open) puts the client back on every page, because a root renders data-controller="reactive" and that is what makes Stimulus load and connect it. Measured on such a page (issue #274), with the controller pinned preload: false and no other reactive root:
- Without the dialog: 21 script modules on load, no reactive client fetched.
- With the dialog, never opened: 24 script modules, and the client fetched: 68,492 B minified, 20,009 B gzipped, 17,623 B brotli.
- The dialog was reactive so that its list could load on open, which took about 0.1–0.25 KB (gzipped) of inline markup out of each page. Loading the client for it cost roughly a hundred times that in JavaScript.
A dormant root costs nothing until it is used. It renders data-reactive-dormant="reactive" in place of the controller attribute, so nothing mounts and a lazily loaded controller is not fetched. The first trigger that reaches it wakes it: phlex/reactive/early moves the identifier into data-controller, Stimulus loads and connects the controller, and the trigger is replayed.
class ItemsPanel < ApplicationComponent
include Phlex::Reactive::Component
reactive_dormant # every root of this component, inherited
action :load
def view_template
div(**mix(reactive_root, on(:load, event: "panel:opened", once: true))) { … }
end
end
# Or one render at a time:
div(**reactive_root(dormant: true)) { … }import "phlex/reactive/early" in your entry point. Without that module nothing wakes the root: a link or form trigger does its native thing (the link navigates, the form posts), and a dormant root nested inside an awake one hands its triggers to the outer component, which runs its own action of that name or answers 403. bin/rails phlex_reactive:doctor lists the dormant components and names the file it found the import in (an app JavaScript entry, or an inline module script in a view or component). With more than one import map, every map needs the phlex/reactive/early pin: the doctor names each map without it. Set Phlex::Reactive.importmaps = -> { { "landing" => landing_map } } to have it check maps other than Rails.application.importmap.What renders awake
The actor's own reply renders a dormant root awake: the reply only exists because that page's controller is loaded, so a dormant replacement would cost one more wake and save nothing. The same goes for a reactive_lazy(on:) materialize and the defer endpoint. Every other render stays dormant: the page, a broadcast_to (also one fired inside an action), a page refresh, a deferred render pushed over a stream. If one of those lands on a root that is already awake, the root goes back to sleep and the next trigger wakes it again; no trigger is lost.
One case renders awake where you may not want it: a to_stream_replace built inside an action and sent to other pages by hand. Use broadcast_to for those.
With reactive_lazy
An event shell can be dormant: reactive_lazy on: "panel:opened" plus reactive_dormant gives "load this the first time it opens, and do not fetch the reactive controller until then". The event wakes the root and loads it in one request, and the content arrives awake. on: :visible cannot be dormant, because its shell has no trigger to wake on; declaring both raises. A plain reactive_lazy shell always mounts, because it fetches on connect. On a lazy component, declare reactive_dormant on the class: the framework renders the shell, so a per-render reactive_root(dormant: true) in the template never reaches it.
Limits
- Use it for a root whose only behaviour is its triggers. Anything the controller does at connect waits for the wake too:
reactive_persistrestore,reactive_computeseeding, show/filter sync, dirty tracking. The browser inspector (inspect.js) does not list a dormant root. on/on_clienttriggers wake a root, awindow:hotkey pressed anywhere on the page included (issue #303). Anoutside:trigger, and the feature actions (list navigation, tags, nested rows), do not.- When the root also lists a controller of your own, put it first:
mix({ data: { controller: "dropdown" } }, reactive_root). Waking appendsreactiveto the list, so that order matches what an awake reply renders. In the other order, the first morph reply reorders the list and Stimulus reconnects both controllers once. - A dormant root nested inside an awake one belongs to the outer root until it wakes (its fields are collected with the outer root's actions).
- Waking costs
early.js53 bytes: 1,007 B → 1,060 B gzipped (bun, level 9). Recording hotkeys (issue #303) took it to 1,296 B; page-level shortcuts (issue #357) to 1,491 B, its test budget.
What loads when: the default client and the split client#
The client ships as two entries built from the same source (issue #275). An app imports one of them, never both.
phlex/reactive/reactive_controlleris the default: one file with the whole client in it. Everything is there when the file has loaded, and nothing is fetched later.phlex/reactive/coreis opt-in: the controller without its feature modules. It imports a feature the first time a root on the page needs it.
Size is one side of the choice; the other is when each part arrives. Gzipped sizes, as spec/javascript/bundle_budget.test.js measures and holds them:
| Module | Gzipped | Default client | Split client |
|---|---|---|---|
phlex/reactive/reactive_controller | 30.9 KB | When your controllers load (or at the first trigger, for a page whose only roots are dormant). | Never. |
phlex/reactive/core | 14.3 KB | Never: it is inside the one file. | When your controllers load (or at the first trigger, for a page whose only roots are dormant). |
phlex/reactive/features/persist | 3.1 KB | Inside the one file. | When the first root that declares reactive_persist connects, or a persist_state / persist_clear op runs. |
phlex/reactive/features/defer | 2.8 KB | Inside the one file. | When the first reactive_lazy shell connects, a morph turns a root into one, or a reply.defer arrives. |
phlex/reactive/features/form | 1.0 KB | Inside the one file. | When the first root that tracks dirty fields (track_dirty:, warn_unsaved:) or holds a paste_into trigger connects. |
phlex/reactive/features/effects | 1.7 KB | Inside the one file. | When the first root that declares an effect connects, or when the first stream that needs it arrives: one with an effect, or with a dismiss_after: flash. |
phlex/reactive/features/bindings | 6.7 KB | Inside the one file. | When the first root with a reactive_show / show targets / reactive_on_complete / reactive_filter / reactive_tags / reactive_editable / nested-rows binding, or a conditional confirm:, connects. |
phlex/reactive/features/compute | 2.1 KB | Inside the one file. | When the first reactive_compute root connects. Its outputs show the server's values until then: about 20–25 ms with the module served from the same host (measured in the browser suite), one request on a real network. |
phlex/reactive/features/sortable | 3.2 KB | Inside the one file. | When the first root that is, or holds, a reactive_sortable or reactive_sortable_source list connects. |
phlex/reactive/features/presence | 1.3 KB | Inside the one file. | When the first reactive_presence root connects. |
phlex/reactive/features/hints | 1.0 KB | Inside the one file. | When the first root whose trigger declares optimistic: or busy: connects; a click before that waits for it. |
phlex/reactive/features/devtools | 1.6 KB | Inside the one file. | With the core (effectively eager) on a page that carries the phlex-reactive-env development meta or while a latency delay is stored; at connect for a root in debug mode; otherwise the first time a verbose root has something to warn about. In production with none of these, never. |
phlex/reactive/early | 1.5 KB | On every page, if you import it. | The same. |
A page that uses none of these features downloads 16.6 KB less with the split client. A page that uses every one of them downloads 7.7 KB more, in eleven requests instead of one. This is the whole split: everything that can leave the core has. The maintainer's target for the core was 10,240 B; the honest moves leave it at 14,654 B, which the budget test holds as its ratchet (and prints the target against).
The default file is laid out for gzip (issue #310). Gzip only matches text within 32 KB, and the bundler places the features ahead of the runtime in import order, so the order is the one that puts the features repeating the runtime closest to it: about 450 B less than the connect order. With the features' per-root records rewritten as closures (a property name is the one thing a minifier never renames), the default file went from 25,933 B to 24,925 B. The target is 22,700 B (the file before the split, plus a loader's worth); the features added since, bulk selection alone about 800 B, keep it out of reach without changing behaviour.
Two things are not feature modules, in either client. The offline hook (data-reactive-offline on <html>) and the offline fallback flash stay in the core: they are needed exactly when the network is gone, which is when a module cannot be fetched.
What the split client makes you wait for
With the default client a feature is simply there when a root connects. With the split client the first root of a page load that needs a feature waits for one small request. The module is cached afterwards, and on a Turbo visit it is already loaded, so later roots do not wait. These behaviours exist only in that window, and only with the split client:
- A draft is restored a moment after the root connects, not inside the connect. Until then the form shows what the server rendered.
- An action fired in the window waits for the restore, then posts the restored values. If the module is slower than the feature timeout (10 s), fails to load, or the root leaves the page first, the action goes out with the values on the page.
- What the user changes in the window is kept, and drafted when the module arrives, also when they leave the page before it does.
- A lazy shell shows its placeholder a little longer; an
on:event in the window loads the shell once the module is there. - A stream that brings the page's first effect or dismissing flash (a broadcast, an action's reply) is rendered when the effects module has arrived, so that very stream is animated and its flash is dismissed on time:
dismiss_after:counts from the render. Every stream that arrives while it waits renders after it, in order. The wait is capped at one second; after that the streams render without the effect, and a flash they brought is dismissed counting from the module's arrival. A root that declares an effect loads the module when it connects, so this happens only on a page where no root does. - A dirty-tracked form counts an edit made in the window when the module arrives.
warn_unsaved:does not prompt for a navigation in the window itself; preload the form module on a page where that matters. - A form's bindings (
reactive_show,reactive_filter, the tag chips, the nested-row JSON field) and areactive_computeroot show what the server rendered until their module arrives, then seed from the fields as the user left them: a choice made in the window is kept and reflected, an edit is recomputed from the typed value, never overwritten. Both hold the root's requests until the module is here (the JSON-mode rows and the computed outputs are fields a request collects). Preload the module on a page where the first paint must already reflect them. - A trigger with an
optimistic:orbusy:hint: its request waits for the hints module (at most the feature timeout); the hint applies if the module arrives in time, and after a timeout or a failed load the request goes out once without it. The busy markers are on from the click; a failure after a late apply still reverts the optimistic hint. A root whose triggers declare a hint loads the module at connect, so this is the first click of a page's first root at most. - The latency simulator delays a request from the first one on: the request waits for the module. A zero-target warning or a debug trace is logged once the devtools module is here.
- A module that cannot be imported leaves its feature off for that page load: one console error,
data-reactive-error="feature"on the root, and areactive:errorevent.
Taking the small core without the network wait
Preload the features you know a page uses:
# after the engine's own pins
pin "phlex/reactive/core", to: "phlex/reactive/core.min.js", preload: true
pin "phlex/reactive/features/persist", to: "phlex/reactive/features/persist.min.js", preload: true
pin "phlex/reactive/features/form", to: "phlex/reactive/features/form.min.js", preload: true # the unsaved guard a task after connect
pin "phlex/reactive/features/bindings", to: "phlex/reactive/features/bindings.min.js", preload: true # show/filter/tags a task after connect
pin "phlex/reactive/features/compute", to: "phlex/reactive/features/compute.min.js", preload: true # computed fields a task after connectThat preloads the module on every page. To preload it only where it is used, render <link rel="modulepreload"> for it on those pages. The module is then already loaded, so there is no network wait — but a preloaded module still arrives through import(), which resolves in a later task: the feature connects in the task after connect(), not inside it (measured: with modulepreload the compute seed lands 29–51 ms after reactive:connect on localhost), featuresReady is pending for that moment, and the first root's request gate applies to it. The default bundle is the only way to get connect-time seeds.
Verified on
Chromium (the browser suite, under Puma and Falcon). Not run: Firefox or WebKit; a real importmap-rails app with Propshaft digests on the default entry (the docs site is one, and its suite passes, but its network requests were not inspected); the per-root connect() bench on a quiet machine.
With dormant roots and early capture
- Default client, lazily loaded, dormant root: nothing reactive is fetched until the first trigger.
phlex/reactive/earlywakes the root, the one file loads, and the trigger is replayed with every feature already in place. - Split client, lazily loaded, dormant root: the same, in two steps. The core loads and the trigger is replayed; if the root needs a feature, that is one more request, and the replayed trigger waits for it where it has to (a draft-keeping root's action, a lazy shell's load).
- Hotkeys, in both:
phlex/reactive/earlyrecords awindow:trigger (issue #303), which is how a hotkey is usually bound: a hotkey pressed before the controller connects wakes a dormant root and is replayed, if the controller connects within 1.5 s of the keypress (or the early-event TTL, if that is shorter). It is never prevented, so a browser shortcut on the same key still fires. Anoutside:trigger is not recorded.
CI#
The bench job in .github/workflows/main.yml runs the micro suite and the request bench on every PR and uploads the report as the benchmarks artifact. It is run-and-report, never a hard fail — it surfaces trends, it does not gate merges on a flaky threshold. Download the artifact from the PR's checks tab to see the numbers for that branch.
Performance is part of every change#
See .claude/rules/performance.md: any change to a hot path ships with a bench, the README/CHANGELOG/docs are updated, and the JS vendored copy is re-synced. Run /perf to benchmark the current branch and get a written before/after.
Adding a benchmark#
A new hot path gets a new benchmark/micro/<name>.rb. Use the shared harness:
require_relative "../support/boot" # boots the dummy app + schema
BenchSupport.header("my hot path")
BenchSupport.ips { |x| x.report("thing") { thing_under_test } }
BenchSupport.allocations("thing") { thing_under_test }rake bench:micro picks it up automatically (it globs benchmark/micro/*.rb).
A new client hot path gets a benchmark/client/<name>.bench.js that registers its benches with mitata and is imported by benchmark/client/index.bench.js. Drive the controller through its public methods (as benchmark/client/support/harness.js does) — never add a test-only export to the shipped controller, which would trip the vendored-client re-sync rule.