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:

```ruby
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:

```ruby
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)
```

> **Tip:** 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.group`s every dispatch in the browser.

```ruby
Phlex::Reactive.debug = Rails.env.development?
```

```
▼ reactive #todo_42 rename → 200 (48ms)
    params: [title] + collected: [title]
    encoding: json
    streams: replace → #todo_42
    token: refreshed ✓
```

> **Tip:** **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.

```ruby
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)
```

> **Note:** 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:

```shell
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](https://github.com/evanwashere/mitata) + [happy-dom](https://github.com/capricorn86/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:

```shell
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.

> **Note:** 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.

> **Note:** 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.

> **Warning:** 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.

> **Note:** 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](https://github.com/mhenrixon/phlex-reactive/blob/main/.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:

```ruby
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.