Skip to main content

Node Renderer

The React on Rails Pro Node Renderer replaces ExecJS with a dedicated Node.js server for server-side rendering. It eliminates the limitations of embedded JavaScript execution and provides significant performance improvements for production applications.

note

Summary for AI agents: Use this page when the user asks about the Node renderer, ExecJS alternatives, or SSR performance. This is the Pro-level overview; for technical setup, see Node Renderer basics and JS configuration. The Node renderer is required for RSC.

Route map: Start at React on Rails Pro if you're choosing a path. This page is the canonical Node Renderer overview; use the linked install and technical docs below for the deeper implementation details.

Why Use the Node Renderer?

ExecJS embeds a JavaScript runtime (mini_racer/V8) inside the Ruby process. This works for small apps but creates problems at scale:

  • Memory pressure — V8 contexts consume memory inside each Ruby process, competing with Rails for resources
  • No Node tooling — You cannot use standard Node.js profiling, debugging, or memory leak detection tools with ExecJS
  • Process crashes — JavaScript memory leaks can crash your Ruby server
  • Limited concurrency — ExecJS renders synchronously within the Ruby request cycle

The Pro Node Renderer solves all of these by running a standalone Node.js server that handles rendering requests from Rails over HTTP.

The contrast in one picture — the old way runs JavaScript inside Rails, while the Node Renderer runs it in a separate service:

Performance Benefits

MetricExecJSNode Renderer
SSR throughputBaseline10-100x faster
Memory isolationShared with RubySeparate process
Worker concurrencySingle-threaded per requestConfigurable worker pool
ProfilingNot availableFull Node.js tooling
Memory leak recoveryCrashes RubyRolling worker restarts

At Popmenu (a ShakaCode client), switching to the Node Renderer contributed to a 73% decrease in average response times and 20-25% lower Heroku costs across tens of millions of daily SSR requests.

How It Works

  1. Rails sends a rendering request (component name, props, and JavaScript bundle reference) to the Node Renderer over HTTP
  2. The Node Renderer evaluates the server bundle in a Node.js worker
  3. The rendered HTML is returned to Rails and inserted into the view
  4. Workers are pooled and can be automatically restarted to mitigate memory leaks

Because rendering runs out-of-process, the renderer scales concurrency across a worker pool instead of blocking the Ruby request cycle. Rails (on an async server such as Puma or Falcon) multiplexes many requests over HTTP/2; the master process forks workers (default: CPU count − 1), auto-restarts crashed ones, and can do scheduled rolling restarts. Each request is rendered in its own per-request sharedExecutionContext, so concurrent renders never leak data into one another:

By contrast, ExecJS renders one request per V8 context and blocks the Ruby thread for the duration. For concurrent streaming specifically, see Streaming SSR.

Key Features

  • Worker pool — Configurable number of workers (defaults to CPU count minus 1)
  • Rolling restarts — Automatic worker recycling to prevent memory leak buildup
  • Bundle caching — Server bundles are cached on the Node side for fast re-renders
  • Shared secret authentication — Secure communication between Rails and Node
  • Prerender caching — Combined with prerender caching, rendering results are cached across requests

React on Rails Pro stacks several caches, each skipping more work than the one below it. The two Rails-side caches differ in scope: a fragment-cache hit skips even props assembly (the props block never runs), while prerender caching still assembles props but skips the JavaScript evaluation. The renderer cache layers sit on the server-side render path; request-scoped deduplication and browser chunk caching are shown as side optimizations because they do not feed into each other:

(Fragment caching subsumes prerender caching: on a fragment-cache hit the prerender cache is never consulted.)

Getting Started

Quick Setup (Generator)

The fastest way to set up the Node Renderer is with the Pro generator:

bundle exec rails generate react_on_rails:pro

This creates the Node Renderer entry point, configures webpack, and adds the renderer to Procfile.dev.

Manual Setup

For fine-grained control, see the Node Renderer installation section in the installation guide.

Configuration

Configure Rails to use the Node Renderer:

# config/initializers/react_on_rails_pro.rb
ReactOnRailsPro.configure do |config|
config.server_renderer = "NodeRenderer"
config.renderer_url = ENV["REACT_RENDERER_URL"] || "http://localhost:3800"
config.renderer_password = ENV["RENDERER_PASSWORD"]
end

Renderer Password Security

The renderer password secures communication between Rails and the Node Renderer. React on Rails Pro enforces secure defaults by environment:

EnvironmentPassword Required?Behavior
developmentNoOptional — no authentication if unset
testNoOptional — no authentication if unset
(neither set)YesTreated as production-like; RENDERER_PASSWORD required
stagingYesRaises error on boot if RENDERER_PASSWORD is missing
productionYesRaises error on boot if RENDERER_PASSWORD is missing
qa, preview, etc.YesRaises error on boot if RENDERER_PASSWORD is missing

In production-like environments (anything other than development or test), both the Rails app and the Node Renderer will refuse to start without a non-empty password. Additionally, a warning is logged if the password:

  • Matches a known-weak default (e.g. devPassword, myPassword1, password, changeme, admin, secret, test, renderer)
  • Is shorter than 16 characters

Set the same RENDERER_PASSWORD for both sides:

# Set for both Rails and Node Renderer — use a strong random value
export RENDERER_PASSWORD="$(openssl rand -hex 32)"

The Node Renderer reads RENDERER_PASSWORD directly from process.env. On the Ruby side, React on Rails Pro resolves the password in this order:

  1. config.renderer_password (blank values fall through to the next step)
  2. Password embedded in config.renderer_url (for example, https://:password@localhost:3800)
  3. ENV["RENDERER_PASSWORD"]

So setting RENDERER_PASSWORD in the environment is enough unless you intentionally override it in the initializer or URL.

If neither NODE_ENV nor RAILS_ENV is set, the Node Renderer treats the environment as production-like and still requires RENDERER_PASSWORD.

The install generator writes a random password into your config files for development convenience. For production, always set RENDERER_PASSWORD as an environment variable and remove any literal password from version control.

Password Rotation

To rotate the renderer password:

  1. Set the new RENDERER_PASSWORD env var on both the Rails app and the Node Renderer.
  2. Restart both processes. The new password takes effect immediately.

Eliminating Cold-Start Latency in Docker Deployments

When a new container starts, the Node Renderer has an empty bundle cache. The first SSR request triggers a costly 410→retry round-trip where Rails sends the full bundle over HTTP, adding 200ms–1s+ of latency depending on bundle size. In rolling deploys, this affects every new pod.

That round-trip is the difference between a warm and a cold render request:

Pre-seeding the cache (below) makes every fresh renderer take the warm path on its very first request.

Pre-seeding the bundle cache

The pre_seed_renderer_cache rake task stages compiled server bundles directly into the renderer's cache directory, so the renderer finds them immediately on startup.

It supports two modes, both producing the same on-disk cache layout (<cache>/<bundleHash>/<bundleHash>.js):

  • MODE=copy (default) — copies files. Use in Docker/image builds so the cache is baked into an immutable artifact.
  • MODE=symlink — creates relative symlinks. For same-filesystem workflows (local dev, CI, Heroku-style same-dyno deploys, bundle-caching restores).
# After webpack/assets build step (Docker image build)
ENV RENDERER_SERVER_BUNDLE_CACHE_PATH=/app/.node-renderer-bundles
RUN bundle exec rake react_on_rails_pro:pre_seed_renderer_cache

Both modes stage the server bundle, any configured assets_to_copy, and (when RSC is enabled) the RSC bundle and its companion manifests.

The pre_seed_renderer_cache task is also invoked automatically at the end of assets:precompile, defaulting to MODE=symlink so the local/CI/Heroku path has zero new configuration. To bake the cache into a Docker image when assets:precompile is the final asset step (rather than calling the rake task explicitly), set ASSETS_PRECOMPILE_RENDERER_CACHE_MODE=copy in the build environment:

ENV RENDERER_SERVER_BUNDLE_CACHE_PATH=/app/.node-renderer-bundles
ENV ASSETS_PRECOMPILE_RENDERER_CACHE_MODE=copy
RUN bundle exec rake assets:precompile

Invalid values raise a clear error listing the accepted modes (copy, symlink).

note

The older react_on_rails_pro:pre_stage_bundle_for_node_renderer rake task and ReactOnRailsPro::PrepareNodeRenderBundles class are deprecated in favor of the unified API. Both remain available as thin shims that emit a deprecation warning and delegate to MODE=symlink. react_on_rails:doctor flags deploy scripts that still reference the deprecated task.

Configuration

The task follows the same environment-variable precedence as the Node Renderer, while the default fallback can differ between Ruby and standalone Node environments:

  1. RENDERER_SERVER_BUNDLE_CACHE_PATH environment variable (preferred)
  2. RENDERER_BUNDLE_PATH environment variable (deprecated — emits a warning)
  3. Rails.root.join(".node-renderer-bundles") (Rails-side default when env vars are unset, only accepted for MODE=symlink and in dev/test)

In MODE=copy (Docker image builds) the task requires one of the env vars above to be set in non-dev/test environments. "Non-dev/test" means any RAILS_ENV other than development or test — including custom environments like staging, review, or ci — so set RENDERER_SERVER_BUNDLE_CACHE_PATH wherever you run MODE=copy outside of local/CI-test runs. Because the Node renderer's own default can differ (e.g., falling back to /tmp/react-on-rails-pro-node-renderer-bundles when its cwd sits outside the app tree), relying on the silent fallback risks pre-seeded bundles landing in a directory the renderer never reads. The task raises a clear error if the env var is missing:

ENV RENDERER_SERVER_BUNDLE_CACHE_PATH=/app/.node-renderer-bundles
RUN bundle exec rake react_on_rails_pro:pre_seed_renderer_cache

Impact

ScenarioBeforeAfter
First request on fresh deploy410→retry: 200ms–1s+Direct render: <50ms
Thundering herd on new podN requests queue behind per-bundle lockAll requests served immediately

Rolling deploys: seed current and previous bundle hashes

During a rolling deploy, new renderer instances can receive requests for both the current deployed bundle hash and the previous hash while old Rails instances drain. Treat this as a two-hash cache-seeding problem, not a single-file problem — and each seeded hash must carry its own companion loadable-stats.json / RSC manifests built in lockstep with that bundle.

pre_seed_renderer_cache handles the current bundle. For previous hashes, configure a rolling_deploy_adapter that:

  • Publishes each successful deploy's bundle + companion assets to an artifact store (S3, Control Plane image registry, etc.) via its upload method — called automatically after assets:precompile in production-like environments.
  • Advertises recent deploys' bundle hashes via previous_bundle_hashes.
  • Retrieves the bundle + assets for a given historical hash via fetch.
# config/initializers/react_on_rails_pro.rb
ReactOnRailsPro.configure do |config|
config.rolling_deploy_adapter = MyApp::S3RollingDeployAdapter
end

During the next build, pre_seed_renderer_cache calls previous_bundle_hashes, deduplicates against the current hash, then fetches and stages each into <cache>/<bundleHash>/... — preventing 410→retry for draining-version requests.

See Rolling-Deploy Adapters for the full protocol spec, reference implementations (S3, Control Plane, Filesystem), and a discussion of the loadable-stats wrinkle.

Observability with OpenTelemetry

The Node Renderer ships an optional OpenTelemetry integration for distributed tracing. When enabled, every SSR request becomes a trace you can inspect in any OTLP-compatible backend (Jaeger, Honeycomb, Datadog, Grafana Tempo, New Relic, etc.).

Install the OpenTelemetry packages (peer dependencies)

npm:

npm install \
@opentelemetry/api \
@opentelemetry/sdk-trace-node \
@opentelemetry/sdk-trace-base \
@opentelemetry/resources \
@opentelemetry/semantic-conventions \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/instrumentation \
@opentelemetry/instrumentation-http \
@fastify/otel

yarn:

yarn add \
@opentelemetry/api \
@opentelemetry/sdk-trace-node \
@opentelemetry/sdk-trace-base \
@opentelemetry/resources \
@opentelemetry/semantic-conventions \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/instrumentation \
@opentelemetry/instrumentation-http \
@fastify/otel

pnpm:

pnpm add \
@opentelemetry/api \
@opentelemetry/sdk-trace-node \
@opentelemetry/sdk-trace-base \
@opentelemetry/resources \
@opentelemetry/semantic-conventions \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/instrumentation \
@opentelemetry/instrumentation-http \
@fastify/otel

Enable from your renderer entrypoint

OpenTelemetry must be initialized before the Fastify server starts so that the auto-instrumentation can patch the modules at require-time. Call init() first in your entrypoint:

import { init as initOpenTelemetry } from 'react-on-rails-pro-node-renderer/integrations/opentelemetry';

initOpenTelemetry({
serviceName: 'my-app-node-renderer', // optional; defaults to "react-on-rails-pro-node-renderer"
fastify: true, // register HTTP + Fastify auto-instrumentation
tracing: true, // wrap SSR rendering in spans
});

// Now start the renderer:
const { reactOnRailsProNodeRenderer } = await import('react-on-rails-pro-node-renderer');
await reactOnRailsProNodeRenderer().catch((e) => {
throw e;
});
note

With fastify: true, OpenTelemetry patches the HTTP and Fastify modules process-wide. If a later init step fails after those patches are installed, OpenTelemetry does not provide a rollback API; the patched modules remain installed and use a no-op tracer until the process restarts.

Configuration via standard OpenTelemetry environment variables

Env varPurposeDefault
OTEL_EXPORTER_OTLP_ENDPOINTOTLP collector endpointhttp://localhost:4318
OTEL_EXPORTER_OTLP_HEADERSAuth headers (e.g. api-key=xxx)none
OTEL_SERVICE_NAMEService name in traces (overrides init({ serviceName }))react-on-rails-pro-node-renderer
OTEL_RESOURCE_ATTRIBUTESAdditional resource attributes (csv); service.name applies when OTEL_SERVICE_NAME and init({ serviceName }) are unsetnone
OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARGTrace samplingparent-based, always-on

Span taxonomy

SpanWhereAttributes
ror.ssr.requestRoot span for each SSR render request(none — root)
ror.bundle.build_execution_contextLoading a bundle into the VMbundle.timestamp, bundle.paths.count, cache.strategy
ror.bundle.uploadWhen new bundles are uploaded mid-request or via /upload-assetsbundle.count, assets.count, bytes.total (sum of upload source size)
ror.vm.executeThe actual SSR JS execution inside the VMbundle.timestamp
ror.result.prepareBuilding the response payloadresponse.bytes (UTF-8 byte length; omitted for streamed responses)
ror.incremental.streamWraps the incremental NDJSON request lifecycle(none)
ror.incremental.process_chunkProcessing each NDJSON update chunk(none)

Outbound HTTP calls inside your SSR bundle are automatically captured by HttpInstrumentation as child spans.

Cache-miss note: On a cache-miss path ror.bundle.build_execution_context appears twice. The first span has cache.strategy=cache-first and can end with ERROR status when the VM cache probe misses. The second span has cache.strategy=cache-miss for the real VM build after bundle upload or bundle discovery. Scope error alerts to exclude cache.strategy=cache-first when that miss is expected.

As a trace, the spans nest under the root ror.ssr.request. On the warm path the spans fire in order: ror.bundle.build_execution_context (cache-first) → ror.vm.executeror.result.prepare. Cold-path spans (upload and cache-miss build) appear only between the first two; outbound fetch calls from your bundle are captured automatically as HTTP child spans; and incremental (async-props) renders add their own stream/chunk spans:

Production defaults

  • Span processor: BatchSpanProcessor in production (NODE_ENV=production or RAILS_ENV=production), SimpleSpanProcessor otherwise. Override with init({ spanProcessor }).
  • Exporter: OTLP HTTP. Override with init({ exporter }).
  • Graceful shutdown: Pending batched spans are flushed when Fastify's onClose hook fires (during worker shutdown), so traces are not lost on rolling restarts. The renderer waits up to 5000ms by default before continuing worker shutdown; override with init({ shutdownTimeoutMs }). The worker also has a 10s app.close() watchdog, so keep custom OTel shutdown timeouts below that window.

Privacy note

The renderingRequest payload and rendered response body are never included in span attributes. Only bundle hashes, counts, and byte sizes (bytes.total, response.bytes) are recorded. This matches the renderer's existing logging policy.

Further Reading