[EXPERIMENT] Send render request body as JSON (closes #3280 hypothesis)#3293
[EXPERIMENT] Send render request body as JSON (closes #3280 hypothesis)#3293AbanoubGhadban wants to merge 1 commit into
Conversation
For issue #3280. Steady-state render requests (send_bundle=false) now go out as application/json instead of application/x-www-form-urlencoded. Profiling on /mega_benchmark_traditional (props ~1.16 MB) attributes ~117 ms wall + 24 ms GC per request to HTTPX::Transcoder::Form.encode percent-escaping ~500k delimiters in the JSON payload, and ~97 ms more on the Node side to URL-decode the body via @fastify/formbody. JSON.generate on the same data is ~1 ms with one allocation. When send_bundle=true (warmup / new bundle), keep the multipart path because form_with_code embeds bundle and asset files as Pathname/IO, which JSON.generate can't serialize. Streaming path (render_code_as_stream) is intentionally left untouched per the issue's scope. Experiment-scope change only — no tests, no lint, no docs, no version negotiation, no migration story. A production-quality implementation will be opened in a separate issue once we know the numbers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Code ReviewOverviewThis experiment PR swaps the The change is tiny and correct in its current form, but the items below should be resolved before a production PR is opened. Issues1. No tests for
|
| if send_bundle | ||
| perform_request(path, form: form) | ||
| else | ||
| perform_request(path, json: form) |
There was a problem hiding this comment.
Watch out for a subtle nil vs omitted difference here. Utils.common_form_data includes "dependencyBundleTimestamps" => dependencies where dependencies is nil when RSC support is disabled. With form encoding that value is typically omitted or sent as an empty string; JSON.generate will produce "dependencyBundleTimestamps":null. The node renderer needs to explicitly handle null for that field — worth a unit test and a Node-side check before the production PR.
| # Experiment (issue #3280): use JSON for steady-state render to skip | ||
| # form-urlencode CPU/GC. Keep multipart when send_bundle=true because | ||
| # form_with_code embeds the bundle file as a Pathname which can't be | ||
| # JSON-serialized. |
There was a problem hiding this comment.
4-line block comment and "Experiment" language are fine for an experiment branch, but project conventions (CLAUDE.md) cap comments at one line and restrict them to non-obvious WHY. For the production PR this should be reduced to something like:
| # Experiment (issue #3280): use JSON for steady-state render to skip | |
| # form-urlencode CPU/GC. Keep multipart when send_bundle=true because | |
| # form_with_code embeds the bundle file as a Pathname which can't be | |
| # JSON-serialized. | |
| # Keep multipart when send_bundle=true — bundle Pathname can't be JSON-serialized. |
Greptile SummaryThis experiment PR replaces the
Confidence Score: 4/5Safe to keep as a benchmark branch; not safe to merge into a release without adding renderer version negotiation, tests, and applying the same JSON flip to the streaming path. The change is a single conditional in one method, backed by thorough benchmark data and correct use of the already-exercised JSON encoding path. The streaming counterpart (render_code_as_stream) still sends multipart, creating asymmetric behavior. No renderer capability check exists, so a renderer that does not support application/json on the render endpoint would break silently. Both gaps are acknowledged in the PR description as intentional experiment scope. react_on_rails_pro/lib/react_on_rails_pro/request.rb — specifically the render_code_as_stream method and the absence of a content-type/version guard around the new JSON branch. Important Files Changed
|
| if send_bundle | ||
| perform_request(path, form: form) | ||
| else | ||
| perform_request(path, json: form) | ||
| end |
There was a problem hiding this comment.
No renderer-side version guard before switching content type
Switching from multipart/form-data to application/json is a wire-protocol change. Any Node renderer that has disabled Fastify's default JSON body parser (e.g. a custom plugin stack, an older version, or a user-patched renderer) will start responding with 415 Unsupported Media Type or silently misparse the body, and the gem has no way to detect or fall back. The existing STATUS_INCOMPATIBLE check only fires when the renderer explicitly returns that status code; it won't catch a JSON-parse failure on the renderer side. The PR description calls this out for the follow-up, but it is worth documenting in a TODO comment near the branch so the production PR doesn't inadvertently ship without it.
|
Closing — this was the intentionally-throwaway experiment for #3280 (no tests/version-negotiation/docs, per the issue's scope). The full experiment is now preserved in the planning doc, including the verbatim diff, all benchmark numbers, the operational problems hit, and a phased production plan: → #3299 — Production work will be a fresh PR per §5 of that doc (protocol-version-gated, with tests + docs). The orthogonal #3217 (TCP_NODELAY) is unaffected and left open. |
Planning artifact for #3280 — **docs only, no code change**. Lives in `internal/planning/` per repo convention (alongside `MONOREPO_MERGER_PLAN.md`, `js-pro-package-separation-plan.md`, etc.). Companion to experiment PR #3293. This is the production-quality follow-up plan the issue asked for once we knew the numbers moved: it records the change, the full benchmark results, the operational problems hit while running the experiment, the deep compatibility/protocol findings, and a phased production implementation plan. > Supersedes the earlier (closed) #3298, which targeted `upcoming-v16.3.0` and put the file under `react_on_rails_pro/`. This one targets `main` and uses `internal/planning/`. ## What's in `internal/planning/json-render-body-migration-plan.md` - **The change** — the 9-line `render_code` conditional and why no renderer change is needed (Fastify default JSON parser; `extractBodyArrayField` already handles both `key` and `key[]`; auth reads `req.body.password` either way). Includes the runtime instrumentation proof (Ruby↔Node byte-for-byte match on 3 payload sizes). - **Performance numbers** — sequential sweep, isolation, autocannon, web-vitals, three-methodology robustness (sequential / paired-curl / parallel-A/B), the #3217 (TCP_NODELAY) orthogonality re-verification, and byte-equivalence. Headline: mega page **396 → 92 ms wall (−304), 112.8 → 9.8 ms GC (−103)**, ~2× the acceptance bar. - **13 operational problems faced** — each with the workaround used. - **Production plan** — compat matrix + protocol-version gating, multipart preservation, streaming out-of-scope, tests, docs/changelog, defensive audit, sequencing vs #3217. ## Links | | | |---|---| | Issue | #3280 | | Experiment PR (demo) | #3293 | | Experiment commit | `352743ef1` | | Demo-app harness commit | [`b90977ef3`](AbanoubGhadban/rsc-benchmar@b90977e) | | Demo-app branch | https://github.com/AbanoubGhadban/rsc-benchmar/tree/experiment/3280-json-render-body | | Orthogonal PR | #3217 | 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Added planning document for a render request body format experiment: includes experiment summary, benchmarking and correctness results, operational findings, and a staged production implementation, testing, and rollout plan. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/shakacode/react_on_rails/pull/3299) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Experiment for #3280. Not for merging as-is — no tests, no version negotiation, no docs, no migration story. Per the issue's scope: implement in the simplest possible way and measure.
Change
One-line conditional in
react_on_rails_pro/lib/react_on_rails_pro/request.rb([+9, -1]):send_bundle: truekeeps the multipart path becauseform_with_codeembeds the bundle as aPathnamewhich JSON.generate can't serialize. Streaming render (render_code_as_stream) and incremental render (render_code_with_incremental_updates) are intentionally untouched per the issue's scope. No Node-side change is needed — Fastify's built-in JSON parser already handlesapplication/jsonfor the/bundles/.../render/...endpoint.Headline results
Workload:
/mega_benchmark_traditional?u=500&p=1000&c=5000on rsc-benchmar'sapps/ror-rsc(props ~1.16 MB, HTML ~2.18 MB). Branch base:upcoming-v16.3.0(3d571c32).Sequential sweep (n=25 per point, 110-call warmup)
Improvement scales with payload size, exactly as the encoding-cost hypothesis predicts:
Isolation (n=30 per endpoint,
u=500&p=1000&c=5000)build_only(controller alloc, no renderer call) is unchanged — confirms the savings live in the renderer round-trip path the change touches, not in generic system warmup:Concurrent load (autocannon
--sweep --duration=20)Traditional SSR pages (the change applies):
Streaming SSR (
hello_server,mega_benchmark) is flat ±noise — they go throughrender_code_as_stream, which we left untouched, and the bench confirms no regression.Robustness — three independent measurement methodologies agree
To rule out time-varying environmental noise (thermal throttle, OS background activity, CPU governor drift), I also ran two complete Rails+renderer stacks side-by-side simultaneously on the same machine: exp on :3000/:3800 (with the change), baseline on :3002/:3810 (without).
All three within ~50 ms of each other. The slightly smaller delta on the simultaneous methods is symmetric CPU contention from two stacks sharing a 12-core box; the comparison stays fair because both sides see the same interference.
Robustness — does PR #3217 (TCP_NODELAY) eat the win?
Also tested with the #3217 TCP_NODELAY monkey-patch applied to baseline only (verified via
HTTPX::TCP.ancestors.include?(ReactOnRailsPro::HttpxTcpNodelayPatch) == true). Re-ran paired-curl + parallel autocannon:The win is unchanged. #3217 targets Nagle + delayed-ACK on small writes — but the renderer round-trip is large-burst (1.16 MB props, 2.18 MB HTML), so Nagle never engages, and on localhost loopback (min-RTT ~7 µs) the 40 ms tail doesn't form anyway. The two PRs target orthogonal bottlenecks: #3217 helps network-RTT-bound scenarios; #3280 helps payload-encoding-bound scenarios.
Correctness
Captured HTML before and after for
/hello_world,/heavy_benchmark_traditional,/mega_benchmark_traditional?u=500&p=1000&c=5000. All three byte-equal: 7,690 / 59,713 / 2,186,638 bytes.diffshows only the per-request random UUID drift onHelloWorld-react-component-<uuid>DOM ids — same drift you see between two consecutive baseline calls.Setup
experiment/3280-json-render-bodyoffupcoming-v16.3.0(3d571c32)352743ef1— 9 added, 1 modified lines inrequest.rbAbanoubGhadban/rsc-benchmarbranchfair-comparison-fixes,apps/ror-rscRecommendation
Verdict: Go. Open a production-quality follow-up issue that addresses everything this experiment intentionally skipped:
Backward compatibility with older node renderers. The gem speaks JSON only against renderers that have Fastify's default JSON parser enabled (all current versions do, but worth confirming the floor). A content-type negotiation or version handshake may be needed if any users are on a custom-configured Fastify that disabled the default JSON parser.
Bundle-warmup path stays multipart — already handled by the
send_bundleconditional, but worth adding a test that exercises the warmup path so a future refactor doesn't break it.Apply the same flip to
render_code_with_incremental_updates? It already usesapplication/x-ndjson, but the initial line is JSON-in-multipart-envelope — same opportunity in principle. Out of scope here; needs separate measurement.Tests covering the conditional
form:/json:choice, including a regression test that confirms byte-equivalent HTML output across both encodings.Docs / changelog entry calling out the wire-protocol change and the perf characteristics.
Defensive audit of
form_with_code— confirm there's no future code path that could put a non-JSON-serializable value into the hash whensend_bundle: false.#3217 (TCP_NODELAY) and this PR are orthogonal optimizations. Merging both gives the cumulative benefit of each on whichever workload it targets — payload-bound (mega-page) gets the JSON win, RTT-bound (smaller frequent writes over real network) gets the TCP_NODELAY win.
🤖 Generated with Claude Code