You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The Pro Node Renderer currently communicates over HTTP/2, but parts of the request protocol still use encodings that may add avoidable escaping/parsing overhead.
The main hypothesis is not that binary formats magically shrink all payloads. The useful hypothesis is narrower:
Non-streaming render requests currently send the renderingRequest JavaScript source string through application/x-www-form-urlencoded, which percent-encodes and decodes the whole source string. A raw-body request, JSON envelope, or MessagePack envelope may reduce escaping/unescaping overhead while preserving the current HTTP/2 transport and renderer protocol.
This issue should evaluate that hypothesis before considering deeper protocol changes.
Relationship to sibling investigations
This is one of three issues that micro-optimize the same render-request hot path and are gated on the same question — is anything other than React SSR a material cost?: #3582 (replace Fastify), #3583 (the HTTP/2-over-Unix-socket transport issue), and this one (wire encoding). Rather than each independently building a profiler and likely reaching the same "SSR dominates" conclusion, a single shared Phase 0 profiling spike should produce the go/no-go for all three. See "Benchmark Plan → Phase 0". If that spike shows transport + encoding combined are below the materiality threshold, all three can close together.
Rails builds a renderingRequest string. This is executable JavaScript source.
Rails sends renderingRequest plus protocol/auth fields in a form body.
If no bundle file is included, the body is application/x-www-form-urlencoded.
On the Node side, Fastify parses the form body and the renderer executes renderingRequest in the VM.
Relevant code:
Request#form_with_code adds renderingRequest and only attaches bundle/assets when send_bundle is true.
RendererHttpClient.build_form_body uses urlencoded form encoding unless any field is a file.
Bundle upload is rare: normal render requests do not include the bundle; cache misses trigger a 410 retry with bundle upload, and PreSeedRendererCache can avoid that cold path at deploy time.
JSON request body already exists for smaller endpoints
/asset-exists already uses an application/json request body, so the Ruby client already has a JSON body path. This is useful for a small compatibility spike, but /asset-exists is not the main render hot path.
Important Distinction: Outer vs Inner Escaping
There are two escaping layers:
1. Outer transport escaping
This is the layer this issue can improve with a relatively small change.
Current unary render requests put the entire renderingRequest source string inside an urlencoded form field. That means Ruby percent-encodes the string and Node/Fastify parses/decodes it before the renderer can use it.
Sending the JS source as a raw body removes this layer entirely (verbatim bytes); a JSON or MessagePack envelope reduces it (JSON still escapes quotes/backslashes/control chars, MessagePack does not). Raw body is therefore the cleanest fix for this layer — see Candidate 1.
2. Inner JavaScript/props escaping
This is not fixed by MessagePack or JSON as an outer envelope.
The renderingRequest itself is still executable JavaScript source. If props/state are serialized and escaped inside that JS string, then a binary outer envelope does not remove that inner serialization/escaping cost.
Removing the inner layer would require a deeper protocol redesign where Rails sends structured render data, such as:
and Node builds/invokes the render call instead of receiving arbitrary JS source. That is a larger, more breaking project and should not be part of the first encoding spike.
Candidate Changes
Note on ordering: renderingRequest is one dominant string field; the other fields (auth, protocol version, dependencyBundleTimestamps[]) are tiny. So the most effective outer-encoding change is the one that stops escaping that string at all. Raw body does this with zero new dependencies and is therefore both the performance upper bound and the simplest change — test it first as the discriminator (see Benchmark Plan).
1. Unary render: raw body + metadata in headers (recommended; also the discriminator)
Send the renderingRequest JS source as the raw request body (e.g. Content-Type: application/javascript) and move the small protocol/auth fields to request headers. This includes protocolVersion, gem/package version metadata, authentication fields, and dependency bundle timestamps. The render route already reads bundleTimestamp / renderRequestDigest from the URL path, so reading the remaining small metadata fields from headers is a natural extension.
Possible behavior:
When no files are attached, send the JS source verbatim as the body; metadata in headers.
Preserve multipart only for bundle/cache-miss uploads.
Pros:
Removes all outer escaping of the dominant string — verbatim bytes on the wire (strictly better than JSON, which still escapes quotes/backslashes/control chars in the JS source).
No new dependency; trivially debuggable (it's just the JS source).
Equivalent to MessagePack on the dominant string, without a binary dependency — which makes MessagePack largely redundant for this payload.
Cleanest experimental control: if raw body is not materially faster than urlencoded form, outer encoding genuinely does not matter and the whole investigation can stop with confidence.
Cons:
Metadata moves to headers (HPACK-compressed under HTTP/2, so small fields are cheap); array fields like dependencyBundleTimestamps[] need a header-friendly representation.
Node handler must read those fields from headers instead of request.body.
Needs the same new-vs-old compatibility handling as the JSON option.
Likely shares the existing content-length quirk: the buffered-upload path deliberately omits Content-Length because setting it caused Fastify HTTP/2 stream resets in testing (renderer_http_client.rb:249-256). A raw body is also buffered, so the spike should follow the same content-length handling — verify whether the quirk still applies to a raw-body request (and whether it persists if Fastify is later replaced per Investigate replacing Fastify in the Pro Node Renderer #3582) rather than assuming either way.
Header representation options to test for dependencyBundleTimestamps:
Repeated headers, e.g. x-ror-dependency-bundle-timestamp: <hash> once per dependency. This is HTTP-native for repeated values, but Ruby/Node library behavior around repeated request headers must be verified.
A single JSON metadata header, e.g. x-ror-renderer-metadata: {"dependencyBundleTimestamps":["..."]}. This is easier to parse consistently and can carry future small metadata, but it reintroduces JSON for metadata. That is probably fine because metadata is tiny and not the dominant escaped string.
2. Unary render: urlencoded form to JSON envelope
A middle option if raw body is impractical for some field-shape reason.
Possible behavior:
When no files are attached, send unary render requests as application/json.
Preserve multipart only for bundle/cache-miss uploads.
Keep the same envelope fields: renderingRequest, auth fields, protocol version, dependency bundle timestamps, etc.
Node already has Fastify's default JSON parser, so parsing support likely exists without new dependencies.
Pros:
Removes urlencoded percent-encoding of the renderingRequest string.
No new binary-format dependency.
Easy to A/B benchmark against current form encoding.
Lower implementation complexity than MessagePack.
Easier debugging than binary payloads.
Cons:
JSON still escapes quotes, backslashes, and control characters in the JavaScript source string.
Does not remove inner props/JS escaping inside renderingRequest.
Requires compatibility handling for older Node renderers if Rails can be upgraded independently.
3. Unary render: MessagePack envelope
Worth testing only if raw body / JSON show outer encoding is material and binary metadata framing is suspected to matter. MessagePack avoids escaping the JS string by length-prefixing raw bytes — which is the same property raw body (candidate 1) provides for free. So for this payload MessagePack's only edge over raw body is compacter encoding of the small metadata, which is unlikely to be worth a Ruby + Node dependency.
Possible behavior:
Add application/msgpack render request bodies.
Encode the same envelope currently sent in the form/JSON body.
Keep multipart for bundle/cache-miss uploads.
Pros:
Avoids most string escaping at the transport envelope layer.
Can reduce payload size for object-heavy metadata and update chunks.
Dynamic object shape fits this protocol better than Protobuf.
Cons:
Adds Ruby and Node dependencies.
Binary payloads are harder to inspect/debug.
Does not remove inner JS/props escaping inside renderingRequest.
Benefit may be small if VM/React SSR dominates.
Compatibility/version negotiation is required.
4. Incremental render: NDJSON to MessagePack stream
Possible follow-up after unary render measurements.
Possible behavior:
Add a new content type, e.g. application/x-msgpack-stream.
Frame each message with a length prefix followed by MessagePack bytes.
Preserve HTTP/2 bidirectional streaming and response behavior.
Pros:
Removes JSON stringify/parse and newline framing for async prop update chunks.
Avoids malformed-newline edge cases.
May help if many async prop updates carry large structured data.
Cons:
Requires a new streaming parser on Node and encoder/framer on Ruby.
Needs careful handling for partial frames, malformed frames, backpressure, and cancellation.
Harder to debug than NDJSON.
Does not help ordinary unary render unless that path also changes.
5. Protobuf/BSON
Not recommended as the first spike.
Protobuf is attractive only if we redesign the renderer protocol into stable typed messages. The current payload has arbitrary props and a large executable JS string, so Protobuf would likely become a typed envelope around untyped string/bytes fields, losing most of its value while adding schema management.
BSON is also not a natural fit unless we specifically need MongoDB-like types. It adds type surface without clearly solving the renderer's main escaping issue better than MessagePack.
Compatibility Strategy
Keep existing encodings as the default until benchmarked and proven.
Suggested rollout path:
Add a Node renderer parser path for raw unary render bodies, e.g. Content-Type: application/javascript, where the body is the renderingRequest string and metadata comes from headers.
Add a Rails-side opt-in to send unary render requests as raw body when no files are attached.
Keep urlencoded/multipart behavior available for compatibility.
If raw body is beneficial, consider making raw body the default in a minor or major release depending on renderer compatibility constraints.
Evaluate JSON only if raw body has compatibility or field-shape problems.
Only add MessagePack after raw-body-vs-form data shows outer escaping/parsing is material and raw body is not sufficient.
Compatibility expectation: users are expected to run matching React on Rails Pro gem and react-on-rails-pro-node-renderer package versions. This change can still be implemented as a backward-compatible protocol extension by keeping old parsers in the new renderer and using protocolVersion / renderer capability checks to avoid sending the new encoding to an older renderer.
Field-shape note: the render handler currently reads request.body populated by @fastify/formbody / @fastify/multipart with attachFieldsToBody: 'keyValues'. Both raw-body (fields in headers) and JSON (fields as a parsed object) change where and how fields arrive — notably array fields like dependencyBundleTimestamps[], which are repeated name[]=... pairs in form encoding but a real array (JSON) or a header-friendly encoding (raw-body). The handler must accept the new shape for every field, not just renderingRequest.
Backward compatibility matrix to verify:
New Rails client to old Fastify-era Node renderer.
Old Rails client to new Node renderer.
New Rails client to new Node renderer.
protocolVersion is sent and validated correctly in both old and new encodings.
Matching gem/npm renderer versions use the intended encoding.
Mismatched versions either fall back safely or fail with the existing clear protocol/version mismatch error.
All envelope fields (incl. array fields) parse correctly in the new shape.
Cache-miss bundle upload still uses multipart and still works.
Phase 0: measure current cost (shared with #3582 and #3583)
Before adding formats, profile where request time and CPU go:
Ruby body construction and escaping.
Node body parsing.
VM execution.
React SSR.
Response streaming.
This is the same profiling step the sibling issues need; run it once and share the result. Stop if outer request encoding is not material — use the same threshold as #3583 (continue only if transport/connect/body I/O, here encoding + parsing combined, accounts for roughly >=5-10% of request wall time or CPU).
Phase 1: raw-body vs form (the discriminator), then JSON
Test raw body first — it removes all outer escaping of the JS string and is therefore the upper bound on achievable gain:
Current unary render application/x-www-form-urlencoded.
Unary render with the JS source as a raw body, metadata in headers.
Unary render application/json envelope.
Decision logic:
If raw body is not materially better than form → outer encoding does not matter → stop (skip JSON and MessagePack).
If raw body wins → ship raw body (simplest, no deps). Use JSON only if a field-shape constraint makes raw body impractical.
Use scenarios:
Small props.
Large string-heavy props.
Quote/backslash-heavy props.
Unicode-heavy props.
Large but realistic renderingRequest strings.
Many small render requests.
Measure:
Ruby encode time.
Ruby allocated objects/bytes.
Wire payload size.
Node parse time.
Node allocated memory/RSS.
End-to-end p50/p95/p99 latency.
Phase 2: MessagePack only if justified
Compare:
Current urlencoded form.
Raw body.
JSON envelope.
MessagePack envelope.
Use the same unary render scenarios.
Only continue to incremental MessagePack streaming if MessagePack materially beats JSON for the envelope or if incremental NDJSON parse/stringify cost is independently proven significant.
Acceptance Criteria
Establish whether urlencoded outer escaping/parsing is a meaningful cost in real render requests.
Determine whether raw-body unary render requests provide a material improvement over urlencoded form.
Determine whether application/json unary render bodies are useful only as a fallback if raw body is impractical.
Determine whether MessagePack is worth the additional dependency and debugging cost after raw body is measured.
Preserve multipart bundle upload behavior.
Preserve current HTTP/2 transport.
Preserve existing renderer protocol semantics and error behavior.
Avoid a structured render-call protocol redesign unless measurements show the inner JS/props serialization layer is the real bottleneck.
Open Questions
How much time and allocation does URI.encode_www_form add for realistic renderingRequest strings?
How much time and allocation does Fastify form parsing add compared with Fastify JSON parsing?
Does JSON escaping of JavaScript source strings still dominate after removing percent-encoding?
Are quote/backslash-heavy props common enough to matter?
Can the Rails client safely prefer raw-body render requests while maintaining compatibility with older Node renderer packages?
Is JSON needed only as a compatibility fallback if raw body has field-shape or parser issues?
Should content negotiation be explicit through protocolVersion, content type, or a renderer capability check, given that matching gem/npm renderer versions are the expected deployment shape?
Is incremental NDJSON parse/stringify cost material, or is it lost in async prop computation and React streaming?
If it is material, benchmark raw body (JS source verbatim, metadata in headers) against the current urlencoded form. Raw body is the discriminator: it removes all outer escaping of renderingRequest, so it bounds the achievable gain.
If raw body is not materially better than form → stop (do not test JSON or MessagePack — escaping isn't the cost).
If raw body wins → ship it (no new dependency, easy to debug). Fall back to a JSON envelope only if a field-shape constraint makes raw body impractical.
Do not start with JSON-first-then-stop: JSON still escapes the JS string, so a weak JSON result would wrongly look like "encoding doesn't matter" when raw body would have won.
Reach for MessagePack only if raw body is impractical and compact binary metadata is independently shown to matter — unlikely, since msgpack's string handling matches raw body and the metadata is tiny. Do not start with Protobuf/BSON.
Treat a structured render-call protocol as a separate, larger project. It is the path that could remove inner props/JS escaping, but it is much more invasive than changing the outer wire encoding.
Problem
The Pro Node Renderer currently communicates over HTTP/2, but parts of the request protocol still use encodings that may add avoidable escaping/parsing overhead.
The main hypothesis is not that binary formats magically shrink all payloads. The useful hypothesis is narrower:
This issue should evaluate that hypothesis before considering deeper protocol changes.
Relationship to sibling investigations
This is one of three issues that micro-optimize the same render-request hot path and are gated on the same question — is anything other than React SSR a material cost?: #3582 (replace Fastify), #3583 (the HTTP/2-over-Unix-socket transport issue), and this one (wire encoding). Rather than each independently building a profiler and likely reaching the same "SSR dominates" conclusion, a single shared Phase 0 profiling spike should produce the go/no-go for all three. See "Benchmark Plan → Phase 0". If that spike shows transport + encoding combined are below the materiality threshold, all three can close together.
Affected package areas:
react_on_rails_pro/lib/react_on_rails_pro/request.rbreact_on_rails_pro/lib/react_on_rails_pro/renderer_http_client.rbpackages/react-on-rails-pro-node-renderer/src/worker.tspackages/react-on-rails-pro-node-renderer/src/worker/handleIncrementalRenderStream.tsCurrent Protocol Shape
Unary render request
The common hot path is:
renderingRequeststring. This is executable JavaScript source.renderingRequestplus protocol/auth fields in a form body.application/x-www-form-urlencoded.renderingRequestin the VM.Relevant code:
Request#form_with_codeaddsrenderingRequestand only attaches bundle/assets whensend_bundleis true.RendererHttpClient.build_form_bodyuses urlencoded form encoding unless any field is a file.PreSeedRendererCachecan avoid that cold path at deploy time.Incremental render request
Incremental render uses bidirectional HTTP/2 streaming:
Content-Type: application/x-ndjson.JSON.parse.Relevant code:
Request#render_code_with_incremental_updateswritesinitial_data.to_json.AsyncPropsEmitterwrites update chunks to the same request stream.handleIncrementalRenderStream.tsparses NDJSON lines.JSON request body already exists for smaller endpoints
/asset-existsalready uses anapplication/jsonrequest body, so the Ruby client already has a JSON body path. This is useful for a small compatibility spike, but/asset-existsis not the main render hot path.Important Distinction: Outer vs Inner Escaping
There are two escaping layers:
1. Outer transport escaping
This is the layer this issue can improve with a relatively small change.
Current unary render requests put the entire
renderingRequestsource string inside an urlencoded form field. That means Ruby percent-encodes the string and Node/Fastify parses/decodes it before the renderer can use it.Sending the JS source as a raw body removes this layer entirely (verbatim bytes); a JSON or MessagePack envelope reduces it (JSON still escapes quotes/backslashes/control chars, MessagePack does not). Raw body is therefore the cleanest fix for this layer — see Candidate 1.
2. Inner JavaScript/props escaping
This is not fixed by MessagePack or JSON as an outer envelope.
The
renderingRequestitself is still executable JavaScript source. If props/state are serialized and escaped inside that JS string, then a binary outer envelope does not remove that inner serialization/escaping cost.Removing the inner layer would require a deeper protocol redesign where Rails sends structured render data, such as:
{ "componentName": "App", "props": { "...": "..." }, "renderOptions": { "...": "..." } }and Node builds/invokes the render call instead of receiving arbitrary JS source. That is a larger, more breaking project and should not be part of the first encoding spike.
Candidate Changes
Note on ordering:
renderingRequestis one dominant string field; the other fields (auth, protocol version,dependencyBundleTimestamps[]) are tiny. So the most effective outer-encoding change is the one that stops escaping that string at all. Raw body does this with zero new dependencies and is therefore both the performance upper bound and the simplest change — test it first as the discriminator (see Benchmark Plan).1. Unary render: raw body + metadata in headers (recommended; also the discriminator)
Send the
renderingRequestJS source as the raw request body (e.g.Content-Type: application/javascript) and move the small protocol/auth fields to request headers. This includesprotocolVersion, gem/package version metadata, authentication fields, and dependency bundle timestamps. The render route already readsbundleTimestamp/renderRequestDigestfrom the URL path, so reading the remaining small metadata fields from headers is a natural extension.Possible behavior:
Pros:
Cons:
dependencyBundleTimestamps[]need a header-friendly representation.request.body.Content-Lengthbecause setting it caused Fastify HTTP/2 stream resets in testing (renderer_http_client.rb:249-256). A raw body is also buffered, so the spike should follow the same content-length handling — verify whether the quirk still applies to a raw-body request (and whether it persists if Fastify is later replaced per Investigate replacing Fastify in the Pro Node Renderer #3582) rather than assuming either way.Header representation options to test for
dependencyBundleTimestamps:x-ror-dependency-bundle-timestamp: <hash>once per dependency. This is HTTP-native for repeated values, but Ruby/Node library behavior around repeated request headers must be verified.x-ror-renderer-metadata: {"dependencyBundleTimestamps":["..."]}. This is easier to parse consistently and can carry future small metadata, but it reintroduces JSON for metadata. That is probably fine because metadata is tiny and not the dominant escaped string.2. Unary render: urlencoded form to JSON envelope
A middle option if raw body is impractical for some field-shape reason.
Possible behavior:
application/json.renderingRequest, auth fields, protocol version, dependency bundle timestamps, etc.Pros:
renderingRequeststring.Cons:
renderingRequest.3. Unary render: MessagePack envelope
Worth testing only if raw body / JSON show outer encoding is material and binary metadata framing is suspected to matter. MessagePack avoids escaping the JS string by length-prefixing raw bytes — which is the same property raw body (candidate 1) provides for free. So for this payload MessagePack's only edge over raw body is compacter encoding of the small metadata, which is unlikely to be worth a Ruby + Node dependency.
Possible behavior:
application/msgpackrender request bodies.Pros:
Cons:
renderingRequest.4. Incremental render: NDJSON to MessagePack stream
Possible follow-up after unary render measurements.
Possible behavior:
application/x-msgpack-stream.Pros:
Cons:
5. Protobuf/BSON
Not recommended as the first spike.
Protobuf is attractive only if we redesign the renderer protocol into stable typed messages. The current payload has arbitrary props and a large executable JS string, so Protobuf would likely become a typed envelope around untyped string/bytes fields, losing most of its value while adding schema management.
BSON is also not a natural fit unless we specifically need MongoDB-like types. It adds type surface without clearly solving the renderer's main escaping issue better than MessagePack.
Compatibility Strategy
Keep existing encodings as the default until benchmarked and proven.
Suggested rollout path:
Content-Type: application/javascript, where the body is therenderingRequeststring and metadata comes from headers.Compatibility expectation: users are expected to run matching React on Rails Pro gem and
react-on-rails-pro-node-rendererpackage versions. This change can still be implemented as a backward-compatible protocol extension by keeping old parsers in the new renderer and usingprotocolVersion/ renderer capability checks to avoid sending the new encoding to an older renderer.Field-shape note: the render handler currently reads
request.bodypopulated by@fastify/formbody/@fastify/multipartwithattachFieldsToBody: 'keyValues'. Both raw-body (fields in headers) and JSON (fields as a parsed object) change where and how fields arrive — notably array fields likedependencyBundleTimestamps[], which are repeatedname[]=...pairs in form encoding but a real array (JSON) or a header-friendly encoding (raw-body). The handler must accept the new shape for every field, not justrenderingRequest.Backward compatibility matrix to verify:
protocolVersionis sent and validated correctly in both old and new encodings.Benchmark Plan
Phase 0: measure current cost (shared with #3582 and #3583)
Before adding formats, profile where request time and CPU go:
This is the same profiling step the sibling issues need; run it once and share the result. Stop if outer request encoding is not material — use the same threshold as #3583 (continue only if transport/connect/body I/O, here encoding + parsing combined, accounts for roughly >=5-10% of request wall time or CPU).
Phase 1: raw-body vs form (the discriminator), then JSON
Test raw body first — it removes all outer escaping of the JS string and is therefore the upper bound on achievable gain:
application/x-www-form-urlencoded.application/jsonenvelope.Decision logic:
Use scenarios:
renderingRequeststrings.Measure:
Phase 2: MessagePack only if justified
Compare:
Use the same unary render scenarios.
Only continue to incremental MessagePack streaming if MessagePack materially beats JSON for the envelope or if incremental NDJSON parse/stringify cost is independently proven significant.
Acceptance Criteria
application/jsonunary render bodies are useful only as a fallback if raw body is impractical.Open Questions
URI.encode_www_formadd for realisticrenderingRequeststrings?protocolVersion, content type, or a renderer capability check, given that matching gem/npm renderer versions are the expected deployment shape?Current Recommendation
renderingRequest, so it bounds the achievable gain.Treat a structured render-call protocol as a separate, larger project. It is the path that could remove inner props/JS escaping, but it is much more invasive than changing the outer wire encoding.