Node Renderer JavaScript Configuration
Pro Feature — Available with React on Rails Pro. Free or very low cost for startups and small companies. Upgrade or licensing details →
You can configure the node-renderer entirely with ENV values from your own launch file or
package.json script. The package does not ship a standalone node-renderer CLI.
For most apps, create a small configuration file to set up and launch the node-renderer.
The values in this file must be kept in sync with the config/initializers/react_on_rails_pro.rb file, as documented in Configuration.
Here are the options available for the JavaScript renderer configuration object, as well as the available default ENV values if you wire them into your own launch script.
- port (default:
process.env.RENDERER_PORT || 3800) - The port the renderer should listen to. On Heroku or ControlPlane you may want to useprocess.env.PORT. - host (default:
process.env.RENDERER_HOST || 'localhost') - The host/IP address the renderer should bind to. The default oflocalhostkeeps the renderer reachable only from the local machine (or the same Kubernetes pod network namespace), following the same secure-by-default approach as PostgreSQL. Set to0.0.0.0only when the renderer must be reachable across a network boundary (e.g., separate container workloads in Docker Compose or Kubernetes). Security caution: The renderer executes JavaScript sent to it viavm.runInContext(), making it a remote code execution service by design. The Node.jsvmmodule is not a security boundary. Binding to0.0.0.0withoutpasswordauthentication exposes this to anyone on the network. Only bind to0.0.0.0behind private networking or firewall rules, and always setpasswordwhen the renderer is network-accessible. See Network Security for details. - logLevel (default:
process.env.RENDERER_LOG_LEVEL || 'info') - The renderer log level. Set it tosilentto turn logging off. Available levels:{ fatal: 60, error: 50, warn: 40, info: 30, debug: 20, trace: 10 }.silentcan be used as well. - logHttpLevel (default:
process.env.RENDERER_LOG_HTTP_LEVEL || 'error') - The HTTP server log level (same allowed values aslogLevel). - fastifyServerOptions (default:
{}) - Additional options to pass to the Fastify server factory. The options override the renderer defaults, sofastifyServerOptions: { http2: false }is the supported way to select an HTTP/1.1 listener. For a cleartext renderer URL, pair it withconfig.renderer_http_force_http2 = falsein Rails; HTTPS negotiates its protocol through ALPN. HTTP/1.1 supports regular rendering and health checks. Direct connections can stream async props in both directions, but request-buffering or half-duplex intermediaries are not supported for async props. See Health and Readiness Endpoints and the Fastify documentation. - serverBundleCachePath (default:
process.env.RENDERER_SERVER_BUNDLE_CACHE_PATH || process.env.RENDERER_BUNDLE_PATH || '/tmp/react-on-rails-pro-node-renderer-bundles') - Path to a cache directory where uploaded server bundle files will be stored. This is distinct from Shakapacker's public asset directory. For example you can set it topath.resolve(__dirname, './.node-renderer-bundles')if you configured renderer from the/directory of your app. - workersCount (default:
process.env.RENDERER_WORKERS_COUNT || defaultWorkersCount()where default is your CPUs count - 1) - Number of workers that will be forked to serve rendering requests. If you set this manually make sure that value is a Number and is>= 0. Setting this to0will run the renderer in a single process mode without forking any workers, which is useful for debugging purposes. For production use, the value should be>= 1. - password (default:
env.RENDERER_PASSWORD) - The password expected to receive from the Rails client to authenticate rendering requests. Indevelopmentandtestenvironments (checked via bothNODE_ENVandRAILS_ENV), the password is optional — if unset, no authentication is required. In all other environments (staging,production, etc.), the renderer will refuse to start without an explicit password. SetRENDERER_PASSWORDin your environment or passpasswordin the config object. - licenseToken (default:
env.REACT_ON_RAILS_PRO_LICENSE) - The paid React on Rails Pro license JWT. Explicit nonblank configuration takes precedence over the environment variable; blank or omitted configuration falls back to the environment. Configure this process separately from Rails when it runs as a standalone service. Token values are masked from sanitized renderer configuration logs. - allWorkersRestartInterval (default:
env.RENDERER_ALL_WORKERS_RESTART_INTERVAL) - Interval in minutes between scheduled restarts of all workers. By default restarts are not enabled. If restarts are enabled,delayBetweenIndividualWorkerRestartsshould also be set. Recommended for production — rolling restarts are the primary safety net against memory leaks from application code. See the Memory Leaks guide. - delayBetweenIndividualWorkerRestarts (default:
env.RENDERER_DELAY_BETWEEN_INDIVIDUAL_WORKER_RESTARTS) - Interval in minutes between individual worker restarts (when cluster restart is triggered). By default restarts are not enabled. If restarts are enabled,allWorkersRestartIntervalshould also be set. Set this high enough so that not all workers are down simultaneously (e.g., if you have 4 workers and set this to 5 minutes, the full restart cycle takes 20 minutes). - gracefulWorkerRestartTimeout: (default:
env.GRACEFUL_WORKER_RESTART_TIMEOUT) - Time in seconds that the master waits for a worker to gracefully restart (after serving all active requests) before killing it. For example,30means 30 seconds, not 30 milliseconds. Use this when you want to avoid situations where a worker gets stuck in an infinite loop and never restarts. This config is only usable if worker restart is enabled. The timeout starts when the worker should restart; if it elapses without a restart, the worker is killed. - maxDebugSnippetLength (default: 1000) - If the rendering request is longer than this, it will be truncated in exception and logging messages.
- supportModules - (default:
env.RENDERER_SUPPORT_MODULES || null) - If set to true,supportModulesenables the server-bundle code to call a default set of NodeJS global objects and functions that get added to the VM context:{ Buffer, TextDecoder, TextEncoder, URLSearchParams, ReadableStream, process, performance, setTimeout, setInterval, setImmediate, clearTimeout, clearInterval, clearImmediate, queueMicrotask }.performanceis included so React 19's development build can callperformance.now()fromReact.lazywithout throwingReferenceError: performance is not defined. This option is required to equaltrueif you want to use loadable components. Setting this value to false causes the NodeRenderer to behave like ExecJS. See alsostubTimers. - additionalContext - (default:
null) - additionalContext enables you to specify additional NodeJS objects (usually from https://nodejs.org/api/globals.html) to add to the VM context in addition to oursupportModulesdefaults. Object shorthand notation may be used, but is not required. Example:{ URL, Crypto } - stubTimers - (default:
env.RENDERER_STUB_TIMERSif that environment variable is set,trueotherwise) - With this option set, use of functionssetTimeout,setInterval,setImmediate,clearTimeout,clearInterval,clearImmediate, andqueueMicrotaskwill do nothing during server-rendering. This is useful when using dependencies like react-virtuoso that use these functions during hydration. In RORP, hydration typically is synchronous and single-task (unless you use streaming) and thus callbacks passed to task-scheduling functions should never run during server-side rendering. Because these functions are valid client-side, they are ignored on server-side rendering without errors or warnings. Note thatperformance(exposed whensupportModules: true) is the host's realperformanceobject and is not stubbed bystubTimers; if rendered output embedsperformance.now()values (e.g., dev-only timing annotations) they will vary between renders. Override viaadditionalContext(e.g.,{ performance: { now: () => 0 } }) if strict SSR determinism is required. See alsosupportModules. - enableHealthEndpoints - (default:
false; setRENDERER_ENABLE_HEALTH_ENDPOINTStotrue,TRUE,yes,YES, or1to enable) - If set totrue, the renderer registers built-in, unauthenticatedGET /health(liveness) andGET /ready(readiness) probe endpoints with status-only response bodies. See Health and Readiness Endpoints for semantics and working Kubernetes/ECS probe examples. The default h2c listener needs an h2c-aware probe; an explicitly configured HTTP/1.1 listener supports ordinaryhttpGetprobes. - currentGenerationManifestPath (default:
process.env.RENDERER_CURRENT_GENERATION_MANIFEST) - Absolute path to the immutable, revision-scoped current-generation declaration emitted byreact_on_rails_pro:pre_seed_renderer_cache. When configured, every renderer worker validates the declaration and its server plus optional RSC artifacts, compiles the complete set, and pins it as current before listening. In supportedMODE=symlinkdeployments, startup validates and compiles each immutable snapshot target while registering its renderer-facing cache path as the same in-memory VM identity; requests therefore reuse the prewarmed context without resolving the symlink again. Startup fails if the declaration is missing, malformed, oversized, outside the cache declaration directory, resolves an artifact outside the allowed immutable cache roots, or needs more contexts thanmaxVMPoolSize. Point each renderer revision at its own content-addressed declaration; do not create a mutable shared-volumecurrentpointer. - replayServerAsyncOperationLogs (default:
process.env.REPLAY_SERVER_ASYNC_OPERATION_LOGSif set, otherwisetruewhenNODE_ENV=development,falsein other environments) - If set totrue, enables replay of console logs from asynchronous server operations. Iffalse, only logs that occur before any awaited asynchronous operations are replayed. Note: unlikepassword, this option only checksNODE_ENV, notRAILS_ENV— async log replay is a JS debugging concern, so it keys off the JS runtime environment alone. - maxVMPoolSize (default:
4; set viaMAX_VM_POOL_SIZEenv var, parsed as integer) - Absolute maximum number of compiled VM contexts retained in the pool per renderer worker. The default holds the server and RSC contexts for one draining and one current bundle generation. Least-recently-used pooled contexts are evicted when the hard cap is exceeded. Must be a positive integer. - vmPoolRolloutDrainTimeout (default:
60; set viaVM_POOL_ROLLOUT_DRAIN_TIMEOUT, parsed as seconds) - How long an inactive bundle set remains eligible for VM reuse during a rolling deploy. The default covers a representative 40-second overlap plus 20 seconds of drain and scheduling margin. Must be a positive finite number. Tune it to exceed the longest interval during which a draining app revision can still send a render request.
Sizing and draining the VM pool
Use this per-worker sizing formula:
maxVMPoolSize >= simultaneous bundle generations × contexts per generation
- SSR-only uses one context per generation.
- RSC uses two contexts per generation: one server context and one RSC context.
- A renderer shared by old and new RSC-enabled Rails revisions therefore needs
2 × 2 = 4contexts per worker. This is the default. - If your topology guarantees that each renderer receives requests from only one app revision, the minimum is
1for SSR-only or2for RSC. Keep the default4unless the memory savings justify relying on that topology guarantee.
maxVMPoolSize remains an absolute cap on contexts retained in the VM pool. Setting it below the formula does not create temporary rollout headroom: the renderer evicts least-recently-used pooled contexts and may rebuild them as old and new requests alternate. Hard-limit pressure logs are rate-limited and include cumulative build and eviction counters.
The drain timeout bounds how long inactive generation metadata and unshared contexts remain in the pool. Cleanup runs from one unreferenced timer, so it does not require another render request. Shared bundle paths remain pooled while any live bundle set references them. An already-running request can keep an evicted execution context and source-map registration alive outside the pool until it releases them.
With currentGenerationManifestPath, the trusted revision-scoped declaration—not request order—identifies the current bundle set. Old requests cannot demote it, even across an arbitrarily long old-only traffic gap. Inactive nonmatching generations drain on the configured timer, while the declared current contexts remain pinned. Without a declaration, the compatibility path retains the most recently observed successful bundle set and /ready keeps its legacy “any compiled context” meaning.
The pool uses one global per-worker hard cap. Declared current contexts have eviction priority over draining request generations; a draining context that cannot be pooled is still safe for its active request until release. Startup fails instead of weakening the cap when the declared set itself cannot fit. Generation-affinity routing can reduce overlap pressure only when the deployment platform guarantees that every renderer receives one revision, so size the pool for the generations that can actually reach each renderer.
Disk-cache seeding and VM retention solve different cold paths:
pre_seed_renderer_cacheplaces bundle files and companion artifacts on disk, avoiding a410 Gone, upload, and retry, and emits the new revision's immutable current-generation declaration.- Configuring that exact declaration makes each worker compile the new server and optional RSC contexts before it listens.
- A correctly sized VM pool retains those pinned contexts while old request generations drain.
At debug, vm_pool_context_built reports cumulative builds and hard-cap evictions. vm_pool_hard_limit_eviction is a rate-limited warning that indicates the configured cap is causing pressure. vm_pool_rollout_generation_retired reports bounded drain cleanup without logging bundle paths.
The exact fleet-wide upper bound on pooled contexts is:
renderer replicas × effective workers per replica × maxVMPoolSize
workersCount: 0 means one effective in-process worker; otherwise use the configured worker count. During a rollout, use the maximum simultaneously live renderer replicas, including old replicas and maxSurge, not only steady-state replicas. Estimate each replica's memory request and limit from effective workers × maxVMPoolSize × measured memory per pooled context, then add the master process, base V8 heaps, bundle buffers, and headroom for source maps and evicted execution contexts held by concurrent in-flight requests. The total cluster reservation multiplies that per-replica budget by the overlapping replica count.
With currentGenerationManifestPath, startup is the supported eager prewarm seam and /ready means the answering worker compiled its complete declared current set. A safe controlled rollout is:
- Complete pre-seeding and capture the exact emitted declaration path for this renderer revision.
- Set
RENDERER_CURRENT_GENERATION_MANIFESTto that immutable file and start the renderer on private networking. - Wait for the renderer to listen and for
/ready; each worker crosses its own compile barrier before accepting connections. - Deploy the matching Rails revision. Application-owned authenticated smoke requests remain useful for end-to-end behavior, but are no longer the VM prewarm mechanism.
If no declaration is configured, startup remains backward compatible: bundles compile on requests and /ready means only that some VM exists. That mode cannot guarantee a prewarmed new generation through an old-only request gap.
Deprecated options:
- bundlePath - Renamed to
serverBundleCachePath. The old name will continue to work but will log a deprecation warning. - honeybadgerApiKey, sentryDsn, sentryTracing, sentryTracesSampleRate - Deprecated and have no effect. If you have any of them set, see Error Reporting and Tracing for the new way to set up error reporting and tracing.
- includeTimerPolyfills - Renamed to
stubTimers.
Runtime Globals for SSR and RSC
The node renderer executes uploaded JavaScript bundles inside isolated VM contexts. Those contexts do not automatically inherit every global from the host Node.js process.
| Runtime path | Execution environment | Global guarantees |
|---|---|---|
| Client bundle | Browser | Browser APIs such as window, document, and browser fetch are available. Node.js globals are not. |
| Server bundle (SSR) | Node renderer VM context | JavaScript built-ins are available. With the supportModules option enabled, common Node.js globals (Buffer, TextDecoder, TextEncoder, URLSearchParams, ReadableStream, process, performance, timer functions) are injected. |
| RSC bundle payload render | Node renderer VM context for RSC code | Uses the same VM context rules as the server bundle. The bundle is built for RSC, but host Node.js globals still need to be bundled, polyfilled, or injected. |
supportModules injects a fixed set of common Node.js globals; it does not mirror the host global object into the VM. It does not inject fetch, Headers, Request, Response, AbortController, or AbortSignal. Even if the Node.js process that launches the renderer has those globals, code inside the renderer VM will not see them unless you provide them. That means Server Components should not assume that "modern Node" global fetch or fetch-related cancellation APIs are available in the server or RSC bundle.
Warning: Passing
additionalContext: {}(an empty object) still opts the renderer into CommonJS execution mode, where bundle code can callrequire()against the renderer host's module graph. If you do not need any injected globals, useadditionalContext: nullinstead. See the Bundle Architecture Reference for the full security and behavioral implications.
Prefer passing application data from Rails controllers through props when the data belongs to your Rails app. When a Server Component really needs to call an external HTTP API from the renderer, choose one of these approaches:
- Inject host globals through
additionalContext. Node.js 18+ exposesglobalThis.fetch,Headers,Request, andResponseby default (Node.js 18 and 20 mark the fetch API as experimental; fetch became stable in Node.js 21 and remains stable in Node.js 22+ LTS). Start with the guarded example below so the renderer fails fast if any required fetch global is absent. On older or unsupported Node.js versions without these globals, use a bundled HTTP client instead (see option 2). - Import a server-side HTTP client in the component code, such as
undici(recommended for new projects) ornode-fetchv2 (CJS-compatible legacy fallback; v3+ is ESM-only;node-fetchv2 is maintenance-only since 2022, with security fixes only), and let your bundler include it in the RSC/server bundle.
const { reactOnRailsProNodeRenderer } = require('react-on-rails-pro-node-renderer');
const fetchImplementation = globalThis.fetch;
const HeadersImplementation = globalThis.Headers;
const RequestImplementation = globalThis.Request;
const ResponseImplementation = globalThis.Response;
// Set to true only if your Server Components use AbortSignal; requires Node.js 15+.
const componentsUseAbortSignals = false;
if (!fetchImplementation || !HeadersImplementation || !RequestImplementation || !ResponseImplementation) {
throw new Error(
'Your Node.js runtime does not expose one or more required fetch globals (fetch, Headers, Request, Response). ' +
'Use a supported Node.js release that exposes these globals or replace the globalThis.* references above with compatible fetch polyfill imports.',
);
}
if (componentsUseAbortSignals && (!globalThis.AbortController || !globalThis.AbortSignal)) {
throw new Error(
'Your component code uses abort signals, but this Node.js runtime does not expose AbortController and AbortSignal. ' +
'Use a runtime that exposes them or replace the globalThis.* references below with compatible abort polyfill imports.',
);
}
reactOnRailsProNodeRenderer({
supportModules: true,
additionalContext: {
fetch: fetchImplementation,
Headers: HeadersImplementation,
Request: RequestImplementation,
Response: ResponseImplementation,
...(componentsUseAbortSignals
? {
AbortController: globalThis.AbortController,
AbortSignal: globalThis.AbortSignal,
}
: {}),
},
});
Set componentsUseAbortSignals to true when component code creates or accepts AbortSignal values. That guard turns a missing AbortController or AbortSignal into a startup error; without it, the renderer would start and the first component that touches those globals would fail later with ReferenceError. If your components do not use abort signals, set it to false or omit those additionalContext entries. supportModules does not add abort globals.
Install a fetch implementation only when your renderer runtime does not provide these globals, or when you intentionally want a bundled HTTP client instead of the host runtime's implementation. Use this decision guide:
| Situation | Recommendation |
|---|---|
| Supported Node.js LTS host, want to use the runtime's built-in fetch | Use the guarded globalThis.fetch / additionalContext example above. |
| Supported Node.js LTS, ESM or CommonJS, want a bundled HTTP client | Use undici and choose a release compatible with your runtime; see undici's compatibility notes for version pairs. |
| CommonJS launch file on an older runtime that does not expose fetch globals | Use node-fetch v2; node-fetch v3+ is ESM-only. |
For new projects, prefer undici. The undici example below is the primary bundled-client pattern; the node-fetch v2 example after it is a legacy fallback for older or unsupported Node.js installations.
Use the same additionalContext shape with undici as with the globalThis.fetch example above. Unlike node-fetch v2, undici exports fetch as a named export; choose a version compatible with your renderer Node.js runtime. Add the same startup guard so incompatible versions fail before the renderer starts. The example below uses CommonJS; ESM launchers can import the same names from undici and alias them to the *Implementation constants used here.
const { reactOnRailsProNodeRenderer } = require('react-on-rails-pro-node-renderer');
const {
fetch: fetchImplementation,
Headers: HeadersImplementation,
Request: RequestImplementation,
Response: ResponseImplementation,
} = require('undici');
// `undici` does not export `AbortController`/`AbortSignal` as named exports
// (verified against v6 and v8 — they are absent from `Object.keys(require('undici'))`).
// Bundle code that uses abort signals relies on the host globals (Node.js 15+),
// injected via `additionalContext` below.
// Set to true only if your Server Components use AbortSignal; requires Node.js 15+.
const componentsUseAbortSignals = false;
if (!fetchImplementation || !HeadersImplementation || !RequestImplementation || !ResponseImplementation) {
throw new Error(
'The selected undici version does not expose one or more required fetch globals (fetch, Headers, Request, Response). ' +
'Choose an undici release compatible with your renderer Node.js runtime.',
);
}
if (componentsUseAbortSignals && (!globalThis.AbortController || !globalThis.AbortSignal)) {
throw new Error(
'Your component code uses abort signals, but this Node.js runtime does not expose AbortController and AbortSignal. ' +
'Use Node.js 15+, or replace the globalThis.* references below with compatible abort polyfill imports.',
);
}
reactOnRailsProNodeRenderer({
supportModules: true,
additionalContext: {
fetch: fetchImplementation,
Headers: HeadersImplementation,
Request: RequestImplementation,
Response: ResponseImplementation,
...(componentsUseAbortSignals
? {
AbortController: globalThis.AbortController,
AbortSignal: globalThis.AbortSignal,
}
: {}),
},
});
For older or unsupported Node.js installations that cannot use a current undici release, fall back to node-fetch v2 in a CommonJS launch file:
const { reactOnRailsProNodeRenderer } = require('react-on-rails-pro-node-renderer');
// node-fetch v2: the CJS default export is the fetch function itself.
// There is no named "fetch" export, so require("node-fetch").fetch is undefined.
// Headers, Request, and Response are attached as properties on that default export.
const nodeFetch = require('node-fetch');
// A successful require returns the fetch function; the guard below verifies
// the attached fetch classes that node-fetch v2 must provide.
const {
Headers: HeadersImplementation,
Request: RequestImplementation,
Response: ResponseImplementation,
} = nodeFetch;
if (
typeof nodeFetch !== 'function' ||
!HeadersImplementation ||
!RequestImplementation ||
!ResponseImplementation
) {
throw new Error(
'node-fetch v2 did not expose the required fetch function or fetch classes (Headers, Request, Response). ' +
'Ensure node-fetch v2 is installed; v3+ is ESM-only and will not work in this CommonJS launcher.',
);
}
reactOnRailsProNodeRenderer({
supportModules: true,
additionalContext: {
fetch: nodeFetch,
Headers: HeadersImplementation,
Request: RequestImplementation,
Response: ResponseImplementation,
},
});
node-fetch v2 does not ship its own AbortController or AbortSignal implementation, but node-fetch v2.6+ accepts spec-compatible signals when you provide them. If component code uses abort signals, merge the optional abort globals shown in the globalThis.fetch example into additionalContext; on Node.js 18+, the native globalThis.AbortController and globalThis.AbortSignal work with node-fetch v2.6+. On older EOL runtimes, use a compatible polyfill such as node-abort-controller.
Example Launch Files
Testing example:
The repository's dummy app keeps a full integration-test launcher at
react_on_rails_pro/spec/dummy/renderer/node-renderer.js.
Simple example:
Create a file renderer/node-renderer.js. The generator uses this filename and CommonJS syntax so
the file runs directly with node renderer/node-renderer.js without extra ESM configuration.
const path = require('path');
const { reactOnRailsProNodeRenderer } = require('react-on-rails-pro-node-renderer');
const config = {
// Save bundles to relative "./.node-renderer-bundles" dir of our app root
serverBundleCachePath: path.resolve(__dirname, '../.node-renderer-bundles'),
// Optional alternative to REACT_ON_RAILS_PRO_LICENSE. This application-defined
// function can read from any secret provider available to the Node process.
// licenseToken: loadLicenseTokenFromYourSecretManager(),
// All other values are the defaults, as described above
};
// For debugging, run in single process mode
if (process.env.NODE_ENV === 'development') {
config.workersCount = 0;
}
// Renderer detects a total number of CPUs on virtual hostings like Heroku or CircleCI instead
// of CPUs number allocated for current container. This results in spawning many workers while
// only 1-2 of them really needed.
else if (process.env.CI) {
config.workersCount = 2;
}
reactOnRailsProNodeRenderer(config);
And add a root-level script to the scripts section of your package.json
"scripts": {
"node-renderer": "node renderer/node-renderer.js"
},
Run the renderer with pnpm run node-renderer (or the equivalent npm/yarn command for your app).
Built-in Endpoints
The React on Rails Pro node renderer registers /info as a plain GET route outside the authenticated render and asset
endpoints, and it does not use the render or asset authentication prechecks, so it remains accessible without the renderer
password even when password is configured. The route returns node_version and renderer_version. Treat it as a
shallow process check and keep the renderer on localhost or private networking if those runtime version details should
not be exposed.
Verify it locally:
curl -s --http2-prior-knowledge http://localhost:3800/info
Example response:
{
"node_version": "v20.17.0",
"renderer_version": "17.0.0"
}
Custom Fastify Configuration
For advanced use cases, such as adding custom routes, registering Fastify plugins, or hooking into the request lifecycle,
you can configure the Fastify server directly by importing the master and worker modules instead of using
reactOnRailsProNodeRenderer.
The advanced examples below use ES modules for readability. If you want this file to keep running
as node renderer/node-renderer.js, either keep using the CommonJS pattern shown in the simple
example above or switch the file to .mjs or "type": "module".
Adding a Health Check Endpoint
Built-in alternative: The renderer now ships built-in
GET /health(liveness) andGET /ready(readiness) endpoints behind theenableHealthEndpointsconfig option — no custom Fastify code required. See Health and Readiness Endpoints. Use the recipe below only when you need custom probe logic (extra warm-up gates, dependency checks, or custom payloads). If yourconfigureFastifycallback already registers/healthor/ready, remove or rename the custom route before enablingenableHealthEndpoints; Fastify raises a duplicate-route startup error for reused paths. If the duplicate route is registered by an async Fastify plugin duringapp.register()boot, Fastify reports its rawFST_ERR_DUPLICATED_ROUTEerror instead of theenableHealthEndpointsmigration hint.
A common need is a /health endpoint for container health checks:
import masterRun from 'react-on-rails-pro-node-renderer/master';
import run, { configureFastify } from 'react-on-rails-pro-node-renderer/worker';
import cluster from 'cluster';
const config = {
// Your configuration options here
};
// Register a custom health check route
configureFastify((app) => {
app.get('/health', () => {
// Return a Promise or use async/await if warm-up checks involve async operations.
return { status: 'ok' };
});
});
// The node-renderer uses Node.js cluster module to fork worker processes.
// The primary process manages workers; workers handle HTTP requests.
if (cluster.isPrimary) {
masterRun(config);
} else {
run(config);
}
The sample /health route is intentionally shallow and omits handler parameters because it does not need them. Fastify
also passes request and reply to handlers if you need to inspect headers, set status codes, or customize the
response. Add warm-up or readiness-gate logic inside this handler if readiness should wait for renderer-specific
initialization. To signal not-ready while keeping Fastify's return-value style, add reply to the handler parameters,
set the status with reply.code(503), and return a response object from that branch. Do not call reply.send() and
then return another response object.
// Example: signal not-ready while application-specific warm-up runs.
// `workersReady` stands in for any per-worker readiness gate you maintain.
let workersReady = false;
configureFastify((app) => {
// Fastify's `onReady` hook runs once per worker after all plugins finish
// loading. Put any application warm-up here — preload modules, hydrate
// caches, wait for an external dependency — and flip the flag when done.
app.addHook('onReady', async () => {
// await yourWarmUpFunction(); // put async warm-up logic here before flipping the flag
workersReady = true;
});
app.get('/health', (request, reply) => {
if (!workersReady) {
reply.code(503);
return { status: 'warming_up' };
}
return { status: 'ok' };
});
});
The -f flag in curl -sf causes curl to exit non-zero for HTTP 4xx/5xx responses, so a 503 from this handler
correctly fails the probe. Kubernetes exec probes treat any non-zero curl exit code as a failure; the response body is
irrelevant to probe semantics, so you can return whatever payload is useful for debugging, such as
{ status: 'ok', workers: 4 }.
Routes registered with configureFastify do not automatically use the renderer's render and asset authentication
prechecks. A custom /health route like the one above is reachable without the renderer password unless you add your own
Fastify authentication. Keep probe routes shallow and non-sensitive, and keep the renderer on localhost or private
networking.
Registering Fastify Plugins
You can also register Fastify plugins. This example assumes you're using the same cluster setup pattern shown above:
// In the worker branch of your cluster setup (see example above)
import run, { configureFastify } from 'react-on-rails-pro-node-renderer/worker';
import cors from '@fastify/cors';
configureFastify((app) => {
// Register a plugin
app.register(cors, {
origin: true,
});
});
// Add request logging
configureFastify((app) => {
app.addHook('onRequest', (request, reply, done) => {
try {
console.log(`Request: ${request.method} ${request.url}`);
done();
} catch (err) {
done(err);
}
});
});
Note: The
configureFastifyfunction must be called before callingrun(). Multiple callbacks can be registered and will execute in order. You can useapp.ready()in your callback to ensure all plugins are loaded before performing operations that depend on them.
API Stability
The ./master and ./worker exports provide direct access to the node-renderer internals. While we strive to maintain backwards compatibility, these are considered advanced APIs. If you only need basic configuration, prefer using the standard reactOnRailsProNodeRenderer function with the configuration options documented above.
Configuring Startup, Readiness, and Liveness Probes
This section is the canonical source for probe semantics, recommended timing values, and curl command guidance. The copy-paste YAML lives in Kubernetes Sidecar Manifest; update the values here first when tuning, then reflect the change in the manifest.
Keep the three probe types distinct:
- Startup answers whether the renderer has finished booting. Separate it from readiness and liveness so slow startup does not cause premature restarts or block traffic.
- Readiness answers whether the renderer should receive new render requests. Use the built-in
/readyendpoint only when an out-of-band warm-up render can compile a bundle before probe-gated traffic begins. Without that warm-up path, prefer a shallow TCP readiness check or the built-in/infoendpoint, and use/readyfor monitoring or post-deploy verification. See Health and Readiness Endpoints for the full cold-start semantics. - Liveness answers whether the renderer is stuck badly enough that restarting the container is safer. Prefer
tcpSocketas the default so transient CPU or GC pauses do not restart an otherwise recoverable renderer; use an h2c-awareexeccheck only when you intentionally need stricter hung-process detection.
Only the custom /health route requires configureFastify; tcpSocket probes and /info checks work without custom
Fastify setup. The health check route should return 200 OK when the renderer is ready to serve requests, and return a
non-2xx status (for example 503) only when the process should be marked unhealthy for the probe's purpose — readiness,
startup, or stricter liveness.
Security note: See Built-in Endpoints for the note on
/infoexposing runtime version details.
Do not put Rails, database, Redis, or other external dependency checks in the node-renderer's liveness probe. A temporary dependency outage should not restart every renderer replica. If SSR must be available before Rails receives traffic, make the Rails readiness endpoint perform a short renderer check.
The renderer listens with cleartext HTTP/2 (h2c) by default. In that mode, do not configure a Kubernetes httpGet
probe, Control Plane HTTP probe, or any other HTTP/1.1-only probe directly against the renderer port. Use one of these
probe styles instead, or select the paired HTTP/1.1 transport:
| Probe style | When to use it |
|---|---|
tcpSocket | Startup checks, default liveness checks, and fallback readiness when curl with HTTP/2 support is unavailable. |
exec probe | Application-level readiness and optional stricter liveness checks with an h2c-aware client, such as curl --http2-prior-knowledge. |
| HTTP/1.1 | Direct renderer probes when both the Node listener and Rails client use HTTP/1.1, or probes against a separate HTTP/1.1 health endpoint or Rails itself. |
A passing tcpSocket probe means the h2c listener has bound to the port; cluster workers might still be warming up.
Keep an application-level readiness probe if traffic should wait for worker initialization.
For Kubernetes and platform tcpSocket probes, set the renderer host to 0.0.0.0 because those probes connect to the
pod or workload IP, not container-local loopback. The default localhost binding is fine for exec probes that run
inside the renderer container.
For liveness, start with tcpSocket. A fully blocked Node.js event loop may still accept TCP connections and pass that
check, so use an h2c-aware exec liveness probe with a short --max-time only if you explicitly need stricter
hung-process detection and have verified curl HTTP/2 support in the image.
Note: The
execprobe requires curl with HTTP/2 support. Verify withcurl --version | grep -i http2. If unavailable, use atcpSocketprobe as a fallback.
Recommended starting values:
- Startup: Use
tcpSocketon the renderer port (3800by default; use your configuredRENDERER_PORTvalue if different). TCP is enough here because readiness below gates traffic; startup only shields liveness during boot. Start withinitialDelaySeconds: 10(first check fires at 10 s; the sixth and final check fires at10 + ((6 - 1) * 5) = 35 safter container start, and the restart follows once that check actually fails — up totimeoutSecondslater),periodSeconds: 5,failureThreshold: 6, and the Kubernetes defaulttimeoutSeconds: 1for a TCP connection check. - Readiness (custom route): Use
execwithcurl -sf --max-time 3 --http2-prior-knowledge http://localhost:3800/healthafter registering the route withconfigureFastify. Start withtimeoutSeconds: 5,periodSeconds: 5, andfailureThreshold: 3. - Readiness (built-in info): Use
execwithcurl -sf --max-time 3 --http2-prior-knowledge http://localhost:3800/info. Use the same timing settings as the custom-route readiness probe. See the canonical/infosecurity note for the unauthenticated-access caveat. - Readiness fallback: Use
tcpSocketon the renderer port only if curl with HTTP/2 support is unavailable. This checks port reachability, not application readiness. - Liveness: Use
tcpSocketon the renderer port as the default. Start withtimeoutSeconds: 1,periodSeconds: 10, andfailureThreshold: 3, matching the Container Deployment examples. RaisefailureThreshold, and optionallyperiodSeconds, if hard listener checks restart the container too aggressively in your environment.timeoutSeconds: 1assumes a co-located probe over loopback; raise it (typically to2-3) when the renderer is reached over the network, such as the separate-workload topology. - Optional stricter liveness: Use
curl -sf --max-time 3 --http2-prior-knowledge http://localhost:3800/infoonly when you need liveness to catch a blocked event loop and have verified curl has HTTP/2 support in the image. Keep external dependency and warm-up checks in readiness, not liveness.
Substitute 3800 with your actual renderer port in Kubernetes YAML exec arrays; shell variable expansion
does not apply there. See the port option at the top of this page for Heroku or Control Plane.
Note (startup window): With
initialDelaySeconds: 10,periodSeconds: 5, andfailureThreshold: 6:
- First check fires at 10 s.
- Last (6th) check fires at 35 s (
10 + (6 - 1) × 5).- Container restarts after the 6th consecutive failure, up to
timeoutSeconds(1 s here) later.If you omit
initialDelaySeconds, checks start immediately and the last check fires at 25 s ((6 - 1) × 5). IncreasefailureThresholdorperiodSecondsif startup regularly takes longer. ReduceinitialDelaySecondsif the renderer reliably opens its port within 1-2 s, or match it to your actual boot time to suppress noisy early-failure log entries during the warm-up window.
Readiness and liveness omit initialDelaySeconds here because Kubernetes 1.20+ (startup probe GA) defers them until
the startup probe succeeds. If you skip the startup probe or run an older cluster without startup probe support, add an
appropriate initialDelaySeconds to each.
See Kubernetes Sidecar Manifest for a complete pod spec with
all three probes wired in, and
Startup Errors: ERR_STREAM_PREMATURE_CLOSE for
the startup-error troubleshooting context.
For Control Plane topology-specific renderer_url, host binding, and probe target guidance, see
Control Plane Deployment Shapes.