Goal of this issue
This is an AI-agent experiment. Implement the change described below in the simplest possible way (no API design, no version negotiation, no tests, no docs, no migration story) and run the benchmark harness at https://github.com/AbanoubGhadban/rsc-benchmar (PR #4) to measure the impact. Not for merging.
Background
react_on_rails_pro/lib/react_on_rails_pro/js_code_builder.rb embeds the entire props as a JS object literal inside a per-request JS source string:
var usedProps = typeof props === 'undefined' ? {...1.1 MB of JSON-as-JS-literal...} : props;
That source string is what gets sent to the renderer. Consequences:
- Ruby allocates a 1.1 MB JS source string per request on top of the props JSON itself
- The Node renderer's
vm.runInContext must re-parse 1.1 MB of JSON-as-JS source via V8's parser every call
- Any per-request
vm.Script caching (see sister issue) is impossible because the source changes every request
Hypothesis
If the JS source becomes a tiny ~200-byte constant template that reads props from a context-injected variable, and the actual props travel as a separate JSON field:
- Ruby builds a constant ~200-byte JS source (allocation-free)
- Node
JSON.parses 1.1 MB props (~5–10 ms — measured separately) instead of V8 parsing JS literal (~12 ms)
- The JS template becomes cacheable as a
vm.Script
- Wire body shrinks meaningfully (no JSON-as-JS-literal duplication)
What to change (experiment scope only)
- Ruby side —
react_on_rails_pro/lib/react_on_rails_pro/js_code_builder.rb
- Template becomes:
var usedProps = globalThis.__rorpProps; var railsContext = globalThis.__rorpRailsContext; ReactOnRails['serverRenderReactComponent']({ ..., props: usedProps, railsContext, ... });
- Send
props and rails_context as separate JSON fields in the request body (no longer embedded as JS literal)
- Node side —
react_on_rails_pro/packages/node-renderer/src/worker.ts + worker/vm.ts
- Before
vm.runInContext(renderingRequest, context): context.__rorpProps = JSON.parse(body.props); context.__rorpRailsContext = JSON.parse(body.railsContext);
- Clean up after (analogous to the existing
renderingRequest/sharedExecutionContext cleanup)
Best run on top of #3280 (form→JSON) so the body field is just { props: "<JSON>", railsContext: "<JSON>", renderingRequest: "<tiny JS>" }.
Reproduction & measurement
Same setup as #3280 — clone https://github.com/AbanoubGhadban/rsc-benchmar, wire ROR via bundler local paths and yalc, restart renderer + Rails in production, run docs/perf-investigation/02-gc-attribution/run-sweep.sh + run-isolation.sh, compare metrics. Detailed steps in #3280's body — same flow here.
Additionally capture:
- Per-request body bytes on the wire (expect a meaningful drop)
vm.runInContext median for the new tiny template (use the existing bench-render.cjs if helpful)
Acceptance criteria
Not in scope (forbidden for this experiment)
- Designing a stable wire format
- Multi-version protocol negotiation between gem and renderer
- Components that introspect their own JS source
- Tests, lint, type checks, docs
Pages and scenarios to validate (not just /mega_benchmark_traditional)
The experiment must be measured across multiple workload shapes so the change isn't tuned to one payload size. Full plan in docs/perf-investigation/BENCHMARK_SCENARIOS.md in the fork.
Test pages in rsc-benchmar (all at 127.0.0.1:3000 after bundle exec rails s):
| Workload |
Page |
What it isolates |
| Tiny |
/hello_world |
RORP per-request overhead floor, no DB |
| Tiny (no rows) |
/mega_benchmark_traditional?u=0&p=0&c=0 |
Pipeline overhead at zero payload |
| Medium |
/heavy_benchmark_traditional |
~120 KB props / ~250 KB HTML |
| Large |
/mega_benchmark_traditional |
~1.16 MB props / ~2.18 MB HTML — where most overhead becomes visible |
| Large (isolated) |
/mega_benchmark_traditional/build_only and …/send_only |
Separate controller-alloc cost from library-path-alloc cost (GC root-cause routes) |
| RSC streaming |
/hello_server, /heavy_benchmark, /mega_benchmark |
Streaming SSR path (verify the change doesn't regress it) |
| Next.js parallels |
apps/next-traditional/app/heavy/ and apps/next-traditional/app/mega/ |
Same workload, no ROR — sanity check on the gap remaining vs Next |
Three measurement modes (run all three)
1. Sequential timing — Completed … GC: Xms per request
Already-committed harness scripts in the fork:
bash docs/perf-investigation/02-gc-attribution/run-sweep.sh /tmp/exp-after
ruby docs/perf-investigation/02-gc-attribution/analyze.rb /tmp/exp-after
bash docs/perf-investigation/02-gc-attribution/run-isolation.sh /tmp/exp-after-iso
ruby docs/perf-investigation/02-gc-attribution/analyze-iso.rb /tmp/exp-after-iso
Diff against the committed baseline docs/perf-investigation/02-gc-attribution/metrics.csv and iso-metrics.json.
2. Concurrent load — req/s + tail latency
Use the cross-page harness (already in the fork):
node bench/cross-page-autocannon.mjs --sweep --duration=20 --out=/tmp/exp-before
# apply your change, restart renderer + Rails
node bench/cross-page-autocannon.mjs --sweep --duration=20 --out=/tmp/exp-after
diff <(jq -S . /tmp/exp-before/cross-page-*.json) <(jq -S . /tmp/exp-after/cross-page-*.json)
Sweep runs c=1, c=10, c=50 over every page. A change that improves p50 but regresses p99 under c=50 is a no-go.
3. Web vitals — TTFB / LCP / hydration
Playwright snippet at the bottom of BENCHMARK_SCENARIOS.md — relevant if your change might affect TTFB even when total wall is fine (streaming, response-size changes).
Additional scratch harnesses (richer measurement)
A separate scratch tree at /mnt/ssd/my-demos/rsc-benchmark/bench/ on the original investigation machine has more elaborate scripts: run-scale50.mjs, run-highconc.mjs (c=100), measure-rss.mjs (peak RSS sampling), check-correctness.mjs (Playwright correctness verification), run-dashboard.mjs, run-shop.mjs, run-shop-stream.mjs. These were written for a different app layout (Next.js on :3000, ROR Pro on :4000, custom routes like /bench/[scale]) and won't run as-is against rsc-benchmar, but the harness pattern is reusable if you need RSS / multi-scale / ecommerce-shape coverage.
What your report comment must include
| Section |
Content |
| Setup |
Ruby / Node versions, machine specs, jemalloc on/off, which experiments applied (#3280/#3281 may be applied together) |
| Sequential |
metrics.csv diff (sweep) and iso-metrics.json diff (isolation), per-page where applicable |
| Concurrent |
cross-page-autocannon.mjs --sweep JSON before vs after — req/s + p50/p95/p99 per page per concurrency level |
| Web vitals |
TTFB + LCP for at least /mega_benchmark_traditional and /hello_world |
| Correctness |
"Pages render byte-equivalent HTML" or specific diffs noted |
| Verdict |
Go / no-go on opening a production-quality follow-up issue |
Goal of this issue
This is an AI-agent experiment. Implement the change described below in the simplest possible way (no API design, no version negotiation, no tests, no docs, no migration story) and run the benchmark harness at https://github.com/AbanoubGhadban/rsc-benchmar (PR #4) to measure the impact. Not for merging.
Background
react_on_rails_pro/lib/react_on_rails_pro/js_code_builder.rbembeds the entire props as a JS object literal inside a per-request JS source string:That source string is what gets sent to the renderer. Consequences:
vm.runInContextmust re-parse 1.1 MB of JSON-as-JS source via V8's parser every callvm.Scriptcaching (see sister issue) is impossible because the source changes every requestHypothesis
If the JS source becomes a tiny ~200-byte constant template that reads props from a context-injected variable, and the actual props travel as a separate JSON field:
JSON.parses 1.1 MB props (~5–10 ms — measured separately) instead of V8 parsing JS literal (~12 ms)vm.ScriptWhat to change (experiment scope only)
react_on_rails_pro/lib/react_on_rails_pro/js_code_builder.rbvar usedProps = globalThis.__rorpProps; var railsContext = globalThis.__rorpRailsContext; ReactOnRails['serverRenderReactComponent']({ ..., props: usedProps, railsContext, ... });propsandrails_contextas separate JSON fields in the request body (no longer embedded as JS literal)react_on_rails_pro/packages/node-renderer/src/worker.ts+worker/vm.tsvm.runInContext(renderingRequest, context):context.__rorpProps = JSON.parse(body.props); context.__rorpRailsContext = JSON.parse(body.railsContext);renderingRequest/sharedExecutionContextcleanup)Best run on top of #3280 (form→JSON) so the body field is just
{ props: "<JSON>", railsContext: "<JSON>", renderingRequest: "<tiny JS>" }.Reproduction & measurement
Same setup as #3280 — clone https://github.com/AbanoubGhadban/rsc-benchmar, wire ROR via bundler local paths and yalc, restart renderer + Rails in production, run
docs/perf-investigation/02-gc-attribution/run-sweep.sh+run-isolation.sh, compare metrics. Detailed steps in #3280's body — same flow here.Additionally capture:
vm.runInContextmedian for the new tiny template (use the existingbench-render.cjsif helpful)Acceptance criteria
vm.runInContexttime falls below 8 ms (vs ~12 ms today)metrics.csv, vm.runInContext timings, wire body sizesNot in scope (forbidden for this experiment)
Pages and scenarios to validate (not just
/mega_benchmark_traditional)The experiment must be measured across multiple workload shapes so the change isn't tuned to one payload size. Full plan in
docs/perf-investigation/BENCHMARK_SCENARIOS.mdin the fork.Test pages in rsc-benchmar (all at
127.0.0.1:3000afterbundle exec rails s):/hello_world/mega_benchmark_traditional?u=0&p=0&c=0/heavy_benchmark_traditional/mega_benchmark_traditional/mega_benchmark_traditional/build_onlyand…/send_only/hello_server,/heavy_benchmark,/mega_benchmarkapps/next-traditional/app/heavy/andapps/next-traditional/app/mega/Three measurement modes (run all three)
1. Sequential timing —
Completed … GC: Xmsper requestAlready-committed harness scripts in the fork:
Diff against the committed baseline
docs/perf-investigation/02-gc-attribution/metrics.csvandiso-metrics.json.2. Concurrent load — req/s + tail latency
Use the cross-page harness (already in the fork):
Sweep runs c=1, c=10, c=50 over every page. A change that improves p50 but regresses p99 under c=50 is a no-go.
3. Web vitals — TTFB / LCP / hydration
Playwright snippet at the bottom of
BENCHMARK_SCENARIOS.md— relevant if your change might affect TTFB even when total wall is fine (streaming, response-size changes).Additional scratch harnesses (richer measurement)
A separate scratch tree at
/mnt/ssd/my-demos/rsc-benchmark/bench/on the original investigation machine has more elaborate scripts:run-scale50.mjs,run-highconc.mjs(c=100),measure-rss.mjs(peak RSS sampling),check-correctness.mjs(Playwright correctness verification),run-dashboard.mjs,run-shop.mjs,run-shop-stream.mjs. These were written for a different app layout (Next.js on :3000, ROR Pro on :4000, custom routes like/bench/[scale]) and won't run as-is against rsc-benchmar, but the harness pattern is reusable if you need RSS / multi-scale / ecommerce-shape coverage.What your report comment must include
metrics.csvdiff (sweep) andiso-metrics.jsondiff (isolation), per-page where applicablecross-page-autocannon.mjs --sweepJSON before vs after — req/s + p50/p95/p99 per page per concurrency level/mega_benchmark_traditionaland/hello_world