Skip to content

[WIP] [EXPERIMENT] Decouple props from JS source: pass props as a separate JSON field #3281

@AbanoubGhadban

Description

@AbanoubGhadban

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:

  1. Ruby allocates a 1.1 MB JS source string per request on top of the props JSON itself
  2. The Node renderer's vm.runInContext must re-parse 1.1 MB of JSON-as-JS source via V8's parser every call
  3. 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 sidereact_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 sidereact_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

  • Per-request JS source for the render call ≤ 2 KB (vs ~1.1 MB today)
  • Median vm.runInContext time falls below 8 ms (vs ~12 ms today)
  • No regression in total wall time vs the [WIP] [EXPERIMENT] Switch render-request body from form-urlencoded to JSON #3280 baseline
  • Pages render byte-equivalent HTML (sanity check)
  • Report posted as a PR comment with: before/after metrics.csv, vm.runInContext timings, wire body sizes

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

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions