Guide

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, broadcast_*_to_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_in against a memoized off-request view context, instead of ActionController.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 in reactive_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, from action :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 frozen BRACKET_SEGMENT regex (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)#

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 each broadcast_*_to (fires on Action Cable AND pgbus). Payload: component, stream_action, streamables (the key count).

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:

config/initializers/phlex_reactive_apm.rb
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])
end

To 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:

config/initializers/phlex_reactive.rb
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)
The events fire whether or not you enable the LogSubscriber — the flag only controls the gem's own log lines. An unsubscribed instrument is cheap (a few objects per call, zero retained), so the hot paths carry it unconditionally.

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.

config/initializers/phlex_reactive.rb
Phlex::Reactive.debug = Rails.env.development?
browser console
▼ reactive #todo_42 rename → 200 (48ms)
    params: [title] + collected: [title]
    encoding: json
    streams: replace → #todo_42
    token: refreshed ✓
Names and outcomes only. The trace shows the param and collected-field names (never their values — they may be sensitive), the encoding, the status, the response stream actions + targets, whether a token refresh arrived (never the token value), and the round-trip ms. Off (the default) it does nothing — one attribute check per dispatch, no string building — so leave it gated on 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.

config/initializers/phlex_reactive.rb
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)
The diagnostics collector is a nil check on the hot path — with the flag off, 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 dispatch hot path (extractToken, collectFields, recompute) 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) via Rack::MockRequest — the same call-the-app primitive derailed_benchmarks uses — so the numbers reflect production action latency.
  • The client bench (benchmark/client/, run with bun) 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 under app/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/baseline

If 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.

There is no committed baseline file — shared CI runners are too noisy for a hard regression gate — which is exactly why the before/after has to be a deliberate same-machine measurement, not a comparison against a number from another box.

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_component throughput: 6.99k i/s (143 μs) → 14.1k i/s (71 μs) — 2.0× faster.
  • render_component allocations: 212 obj → 99 obj — −53%.
  • to_stream_replace throughput: 4.60k i/s (217 μs) → 8.00k i/s (125 μs) — 1.7× faster.
  • to_stream_replace allocations: 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_replace_to vs broadcast_replace_to_each:

  • 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.

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.

Verdict: verify is not a bottleneck (a garbage flood is ~1.2M i/s, and even a valid verify is ~7 μs against a full action that is ~600 μs), and no digest/serializer configuration wins measurably — so phlex-reactive ships no opt-in recipe and keeps the app default. If your own profile ever shows verify as hot, the setter is there — 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.

Profile FIRST. An app-side N+1 or a missing eager-load looks exactly like framework lag — the feature's own origin story was a scoreboard that "felt slow" per keystroke and turned out to be 2+N queries fixed by one eager load. Make the synchronous path cheap before making it async; defer only a segment that is GENUINELY expensive.

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 three 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). All three are driven through the controller's public surface (dispatch() / recompute()) — 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#extractToken is 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#collectFields and recompute walk 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. #extractToken over 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 full DOMParser parse 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 the DOMParser ceiling, 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. #collectFields via 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 — the closest() scope check per field is skipped entirely. collectFields runs once per dispatch, so this is dominated by the dispatch overhead and the fast path does not move it out of noise.
  • engine-relative. recompute on 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-name querySelectorAll + 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-wins byName memo 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.
The 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.

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.js: 106 KB → 22 KB minified (−79%); ~36 KB → ~7.7 KB gzipped (−78%).
  • confirm.js + compute.js: 7 KB → 0.5 KB combined (the two 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.

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:

benchmark/micro/my_hot_path.rb
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.