Performance
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 (one change fanned out to N subscribers = N renders with no HTTP overhead to amortize against). Measure before you optimize; the harness below exists so you never have to guess.
The hot paths
| Path | When it runs | What we did |
|---|---|---|
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 |
every render + broadcast | Built once per thread per component class and reused. The context is request-bound (Phlex::Reactive.request_bound_view_context, the same mock-request ActionController::Renderer#render sets up) so request-dependent helpers — form_authenticity_token, protect_against_forgery?, host-aware *_url — keep working during a re-render/broadcast (#42). The request setup happens only on the build, not per render, so steady-state throughput/allocations are unchanged. Reset on Rails code reload so a reloaded controller is never served stale. |
reactive_token |
every render (it’s 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 | The bracket-key regex is hoisted to a frozen constant (no per-key recompile). |
| client meta lookups | every dispatch | The page-stable action path is resolved once per controller; CSRF + pgbus connection id stay live (they can rotate). |
Measuring
Everything is driven from rake:
rake bench # the micro-benchmark suite (alias for bench:micro)
rake bench:micro # render, reactive_token, coerce_params — isolates each method
rake bench:request # end-to-end POST /reactive/actions through the full Rack stack
rake bench:one[render] # a single micro-bench by name
- Micro-benches (
benchmark/micro/*.rb) isolate one method withbenchmark-ips(throughput) andmemory_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 primitivederailed_benchmarksuses — so the numbers reflect production action latency.derailed_benchmarksis loaded for its memory/object helpers when you want a deeper dive.
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 can’t
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 — exactly how this PR’s render numbers were
taken.
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 git worktree with the same script as
the after column, so it’s a true same-machine before/after. Your absolute
numbers will differ; the ratios are the point.
| Benchmark | Before | After | Delta |
|---|---|---|---|
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% |
Full-stack request numbers (no clean before/after — see below):
| Benchmark | Throughput | Allocations |
|---|---|---|
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. Don’t expect a render optimization to move request throughput; expect it to move broadcast fan-out (N renders, no HTTP) and to cut GC pressure under load.
- Record-backed actions are ~1.4× slower than state-backed — that’s the GlobalID re-find + DB write, which is the security model working as designed (state lives in the database), not overhead to remove.
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).