Skip to content

Commit 2e490c5

Browse files
Docs: add memory leak prevention guide for Node Renderer SSR (#2845)
## Summary - Rewrites `docs/pro/js-memory-leaks.md` from a 22-line MobX-only stub into a comprehensive guide covering common leak patterns, diagnosis, and mitigations - Adds memory leak awareness across 6 other doc files (troubleshooting, basics, debugging, JS config, deployment tips, llms.txt) ## Context This came from investigating OOM crashes in a production app. The root cause was an unbounded image URL cache at module scope in application code — a pattern that works fine in the browser (where page navigation clears everything) but leaks in the Node Renderer (where the VM context persists across requests). The Node Renderer and React on Rails packages themselves have no growing global state. The issue was entirely in application code, but there was no documentation warning users about these patterns. ## Changes | File | What changed | |------|-------------| | `docs/pro/js-memory-leaks.md` | Complete rewrite with leak patterns (unbounded caches, `_.memoize`, module-level Sets), diagnosis (heap snapshots, `--inspect`), mitigations (`--max-old-space-size`, rolling restarts), and browser vs server mental model | | `docs/pro/troubleshooting.md` | Expanded "Workers crashing with memory leaks" with root cause and investigation steps | | `docs/oss/.../node-renderer/basics.md` | Added "Memory Management" section with essential production recommendations | | `docs/oss/.../node-renderer/debugging.md` | Added "Debugging Memory Leaks" section with heap snapshot workflow | | `docs/oss/.../node-renderer/js-configuration.md` | Added context to restart interval configs explaining they're recommended for production | | `docs/oss/deployment/server-rendering-tips.md` | Added module-level state warning and `--max-old-space-size` tip | | `llms.txt` | Added "SSR Memory Safety" section so LLMs avoid generating leaky SSR code | ## Test plan - [ ] Verify all internal doc links resolve correctly - [ ] Review code examples for accuracy - [ ] Confirm docs render correctly on reactonrails.com after merge Closes #2844 🤖 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 comprehensive memory management guidance for server-side rendering environments, including production configuration recommendations and best practices for preventing memory accumulation in long-running server processes. * New troubleshooting documentation for diagnosing and resolving memory-related issues in deployed applications. * Updated configuration guidance for optimal server resource allocation. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c584ab1 commit 2e490c5

7 files changed

Lines changed: 265 additions & 20 deletions

File tree

docs/oss/building-features/node-renderer/basics.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@
1111

1212
See [Installation](../../../pro/installation.md).
1313

14+
## Memory Management
15+
16+
The Node Renderer reuses V8 VM contexts across requests for performance. This means **module-level state in your server bundle persists across all SSR requests**. Any unbounded caches, `_.memoize` calls, or growing data structures at module scope will leak memory until the worker restarts.
17+
18+
**Essential for production:**
19+
- Set `NODE_OPTIONS=--max-old-space-size=<MB>` to prevent V8 from deferring garbage collection
20+
- Enable worker rolling restarts via `allWorkersRestartInterval` and `delayBetweenIndividualWorkerRestarts`
21+
- Audit your server bundle for module-level mutable state
22+
23+
See the [Memory Leaks guide](../../../pro/js-memory-leaks.md) for common leak patterns and how to fix them.
24+
1425
## Setup Node Renderer Server
1526

1627
**node-renderer** is a standalone Node application to serve React SSR requests from a **Rails** client. You don't need any **Ruby** code to setup and launch it. You can configure with the command line or with a launch file.
@@ -86,7 +97,7 @@ end
8697

8798
## Troubleshooting
8899

89-
- See [JS Memory Leaks](../../../pro/js-memory-leaks.md).
100+
- See [Memory Leaks guide](../../../pro/js-memory-leaks.md).
90101

91102
## Upgrading
92103

docs/oss/building-features/node-renderer/debugging.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,20 @@ directory.
2323
1. Note, the default setup for spec/dummy to reference the pro renderer is to use yalc, which may or may not be using a link, which means that you have to re-run yarn to get the files updated when changing the renderer.
2424
1. Check out the top level nps task `nps renderer.debug` and `spec/dummy/package.json` which has script `"node-renderer-debug"`.
2525

26+
## Debugging Memory Leaks
27+
28+
If worker memory grows over time, use heap snapshots to find the source:
29+
30+
1. Start the renderer with `--expose-gc` to enable forced GC before snapshots:
31+
```bash
32+
node --expose-gc node-renderer.js
33+
```
34+
2. Take heap snapshots at different times using `v8.writeHeapSnapshot()` (triggered via `SIGUSR2` signal or a custom endpoint).
35+
3. Load both snapshots in Chrome DevTools (Memory tab → Load) and use the **Comparison** view to see which objects accumulated between snapshots.
36+
4. Look for growing `string`, `Object`, and `Array` counts — these typically point to module-level caches.
37+
38+
See the [Memory Leaks guide](../../../pro/js-memory-leaks.md) for common patterns and fixes.
39+
2640
## Debugging using the Node debugger
2741

2842
1. See [this article](https://github.com/shakacode/react_on_rails/issues/1196) on setting up the debugger.

docs/oss/building-features/node-renderer/js-configuration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ Here are the options available for the JavaScript renderer configuration object,
2626
1. **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 to `0` will 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`.
2727
1. **password** (default: `env.RENDERER_PASSWORD`) - The password expected to receive from the **Rails client** to authenticate rendering requests.
2828
If no password is set, no authentication will be required.
29-
1. **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, `delayBetweenIndividualWorkerRestarts` should also be set.
30-
1. **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, `allWorkersRestartInterval` should also be set.
29+
1. **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, `delayBetweenIndividualWorkerRestarts` should also be set. **Recommended for production** — rolling restarts are the primary safety net against memory leaks from application code. See the [Memory Leaks guide](../../../pro/js-memory-leaks.md).
30+
1. **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, `allWorkersRestartInterval` should 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).
3131
1. **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. 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.
3232
1. **maxDebugSnippetLength** (default: 1000) - If the rendering request is longer than this, it will be truncated in exception and logging messages.
3333
1. **supportModules** - (default: `env.RENDERER_SUPPORT_MODULES || null`) - If set to true, `supportModules` enables the server-bundle code to call a default set of NodeJS global objects and functions that get added to the VM context:

docs/oss/deployment/server-rendering-tips.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ For the best SSR performance, React on Rails Pro provides a [dedicated Node.js r
66

77
## General Tips
88

9+
- **Module-level state persists across requests in the Node Renderer.** Any `Map`, `Set`, object cache, or `_.memoize` call at module scope will accumulate entries across all SSR requests and never be cleared. This is the most common cause of OOM in Node Renderer production deployments. See the [Memory Leaks guide](../../pro/js-memory-leaks.md) for patterns to avoid.
10+
- **Set `NODE_OPTIONS=--max-old-space-size=<MB>` in production.** Without this, V8 defers garbage collection in containers, amplifying any memory leaks. Size it based on your container memory divided by worker count.
911
- Your code can't reference `document`. Server-side JS execution does not have access to `document`,
1012
so jQuery and some other libraries won't work in this environment. You can debug this by putting in
1113
`console.log` statements in your code.

docs/pro/js-memory-leaks.md

Lines changed: 210 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,219 @@
1-
# JS Memory Leaks
1+
# Avoiding Memory Leaks in Node Renderer SSR
22

3-
## Finding Memory Leaks
3+
> **Pro Feature** — Available with [React on Rails Pro](https://pro.reactrails.com).
44
5-
For memory leaks, see [node-memwatch](https://github.com/marcominetti/node-memwatch). Use the `—inspect` flag to make and compare heap snapshots.
5+
## Why Memory Leaks Happen in the Node Renderer
66

7-
## Causes of Memory Leaks
7+
The Node Renderer reuses [V8 VM contexts](https://nodejs.org/api/vm.html) across requests for performance. Your server bundle is loaded **once** into a VM context and reused for every SSR request until the worker restarts.
88

9-
### Mobx (mobx-react)
9+
This means **module-level state persists across all requests** for the lifetime of the worker process. Code that works fine in the browser — where each page navigation creates a fresh JavaScript context — can silently leak memory on the server.
1010

11-
```js
12-
import { useStaticRendering } from "mobx-react";
11+
```text
12+
Browser: page load → JS context created → user navigates → context destroyed ✓
13+
Node SSR: worker starts → JS context created → request 1, 2, 3, ... 10,000 → same context ✗
14+
```
15+
16+
> **Migrating from ExecJS?** ExecJS creates a fresh JavaScript context per render, so module-level state is automatically cleared. When you switch to the Node Renderer, code that "worked fine" before may start leaking because the same context is now reused across requests.
17+
18+
## Common Leak Patterns
19+
20+
### 1. Module-level caches without eviction
21+
22+
Any module-level `Map`, `Set`, plain object, or array used as a cache will grow unboundedly because the module is loaded once and reused across all requests.
23+
24+
**Leaks:**
25+
```javascript
26+
// cache lives forever — entries are never removed
27+
const cache = new Map();
28+
29+
export function buildSignedUrl(imageUrl, width, height) {
30+
const key = `${imageUrl}-${width}-${height}`;
31+
if (cache.has(key)) return cache.get(key);
32+
33+
const result = computeHmacSignature(imageUrl, width, height);
34+
cache.set(key, result); // grows with every unique input across all requests
35+
return result;
36+
}
37+
```
38+
39+
**Fix:** Add a max size with LRU eviction, clear the cache periodically, or remove it if the computation is cheap:
40+
```javascript
41+
import { LRUCache } from 'lru-cache';
42+
43+
const cache = new LRUCache({ max: 1000 }); // bounded — evicts oldest entries
44+
```
45+
46+
### 2. Lodash `_.memoize` and similar unbounded memoization
47+
48+
Lodash's `_.memoize` uses an unbounded `Map` internally. At module scope, it accumulates entries across all SSR requests forever.
49+
50+
**Leaks:**
51+
```javascript
52+
import _ from 'lodash';
53+
54+
// Each unique argument adds a permanent entry
55+
export const formatLocation = _.memoize((city, state) => {
56+
return `${city}, ${state}`.toLowerCase().replace(/\s+/g, '-');
57+
});
58+
```
59+
60+
**Fix:** Use a bounded LRU cache, or avoid memoization at module scope for functions called with diverse inputs during SSR.
61+
62+
### 3. Module-level Sets or arrays that accumulate
63+
64+
**Leaks:**
65+
```javascript
66+
const SENT_EVENTS = new Set(); // grows with every unique event
67+
68+
export function trackEvent(event) {
69+
if (SENT_EVENTS.has(event.key)) return;
70+
SENT_EVENTS.add(event.key); // never removed
71+
sendToAnalytics(event);
72+
}
73+
```
74+
75+
**Fix:** Don't track client-side-only state (like analytics) during SSR. Guard with a server-side check:
76+
```javascript
77+
export function trackEvent(event, railsContext) {
78+
if (railsContext.serverSide) return; // skip during SSR
79+
// ... client-only tracking
80+
}
81+
```
82+
83+
### 4. Third-party libraries with internal caches
84+
85+
Some libraries maintain internal caches or singletons that grow in SSR:
86+
- **Styled-components / Emotion**: CSS-in-JS libraries can accumulate style sheets. Use `ServerStyleSheet` (styled-components) or `extractCritical` (Emotion) and reset between renders
87+
- **Apollo Client**: GraphQL cache grows if not reset between renders
88+
- **MobX**: Observer components can leak if `useStaticRendering` is not enabled (mobx-react < v7)
89+
- **Amplitude / analytics SDKs**: Event queues accumulate if initialized during SSR
90+
- **i18n libraries**: Message catalogs may cache translations
91+
92+
**Fix:** Check if your libraries have SSR-specific configuration. Many provide a `resetServerContext()` or similar function. Initialize analytics and tracking libraries only on the client side.
93+
94+
### 5. Event listeners at module scope
95+
96+
If code registers event listeners at module scope during SSR, they accumulate across requests:
97+
98+
**Leaks:**
99+
```javascript
100+
// Every SSR render adds another listener — they're never removed
101+
process.on('unhandledRejection', (err) => {
102+
reportError(err);
103+
});
104+
```
105+
106+
**Fix:** Register listeners once (outside the render path), or guard with a flag:
107+
```javascript
108+
let listenerRegistered = false;
109+
if (!listenerRegistered) {
110+
process.on('unhandledRejection', (err) => reportError(err));
111+
listenerRegistered = true;
112+
}
113+
```
13114

14-
const App = (props, railsContext) => {
15-
const { location, serverSide } = railsContext;
16-
const context = {};
115+
## Diagnosing Memory Leaks
17116

18-
useStaticRendering(true);
117+
### 1. Monitor worker RSS over time
118+
119+
Watch the worker process memory. If RSS grows monotonically without plateauing, you have a leak:
120+
121+
```bash
122+
# Check worker memory every 10 seconds
123+
while true; do
124+
ps -o rss= -p <worker-pid> | awk '{printf "%.1f MB\n", $1/1024}'
125+
sleep 10
126+
done
19127
```
20128

21-
- See details here: [Mobx site](https://github.com/mobxjs/mobx-react#server-side-rendering-with-usestaticrendering)
129+
### 2. Take V8 heap snapshots
130+
131+
Use `v8.writeHeapSnapshot()` to capture heap state before and after load, then compare in Chrome DevTools:
132+
133+
```javascript
134+
// In your renderer config, add a way to trigger snapshots:
135+
const v8 = require('v8');
136+
137+
process.on('SIGUSR2', () => {
138+
if (global.gc) global.gc(); // force GC first
139+
const filename = v8.writeHeapSnapshot();
140+
console.log(`Heap snapshot written to ${filename}`);
141+
});
142+
```
143+
144+
Then send `kill -USR2 <worker-pid>` at different times and compare the snapshots in Chrome DevTools (Memory tab → Load).
145+
146+
### 3. Use `--inspect` for live profiling
147+
148+
Start the renderer with the `--inspect` flag to connect Chrome DevTools:
149+
150+
```bash
151+
node --inspect node-renderer.js
152+
```
153+
154+
Open `chrome://inspect` in Chrome, take heap snapshots, and use the "Comparison" view to see what objects accumulated between snapshots.
155+
156+
## Mitigations
157+
158+
### Set `--max-old-space-size`
159+
160+
Without this flag, V8 reads the container's memory limit and sets a very large heap ceiling. This causes V8 to defer garbage collection, amplifying any existing leaks.
161+
162+
**Always set this for production:**
163+
```bash
164+
NODE_OPTIONS=--max-old-space-size=1536 node node-renderer.js
165+
```
166+
167+
Size it based on your container memory and worker count. For example, with 4GB container memory and 3 workers: `4096 / 3 ≈ 1365`, round to `1400`.
168+
169+
### Enable worker rolling restarts
170+
171+
Rolling restarts are the primary safety net against memory leaks. They periodically kill and restart workers, reclaiming all accumulated memory:
172+
173+
```javascript
174+
const config = {
175+
// Restart all workers every 45 minutes
176+
allWorkersRestartInterval: 45,
177+
// Stagger individual restarts by 6 minutes to avoid downtime
178+
delayBetweenIndividualWorkerRestarts: 6,
179+
// Force-kill workers that don't restart within 30 seconds
180+
gracefulWorkerRestartTimeout: 30,
181+
};
182+
```
183+
184+
**Important:** Both `allWorkersRestartInterval` and `delayBetweenIndividualWorkerRestarts` must be set for restarts to be enabled. See [JS Configuration](../oss/building-features/node-renderer/js-configuration.md) for details.
185+
186+
### Size restart intervals for your traffic
187+
188+
The restart interval should be short enough that leaked memory doesn't fill the container:
189+
190+
- **Low traffic / small bundles**: 60–120 minutes may be fine
191+
- **High traffic / large bundles**: 15–30 minutes
192+
- **If you're seeing OOMs**: reduce the interval until stable, then investigate the root cause
193+
194+
## The Browser vs. Server Mental Model
195+
196+
When writing code that runs during SSR, always ask: **"If this module-level variable is never reset, will it grow with each request?"**
197+
198+
| Pattern | Browser | Node Renderer |
199+
|---------|---------|---------------|
200+
| `const cache = {}` at module scope | Cleared on navigation | Persists forever |
201+
| `new Set()` at module scope | Cleared on navigation | Persists forever |
202+
| `_.memoize(fn)` at module scope | Cleared on navigation | Persists forever |
203+
| React component state (`useState`) | Per-component lifecycle | Created and collected per render (OK) |
204+
| `useEffect` callbacks | Runs on client | Skipped during SSR (OK) |
205+
| `useMemo` inside components | Per-component lifecycle | Runs during SSR but result is per-render (OK) |
206+
207+
The rule of thumb: **module-level mutable state is the danger zone.** React component-level state and hooks are fine because React creates and discards them per render.
208+
209+
## Audit Checklist
210+
211+
Use this to scan your server bundle code for potential leaks:
212+
213+
- [ ] Search for module-level `new Map()`, `new Set()`, `const cache = {}`, `[]` — are any of these unbounded?
214+
- [ ] Search for `_.memoize` or `memoize(` at module scope — are they called with diverse SSR inputs?
215+
- [ ] Search for `setInterval` without corresponding `clearInterval` — timers leak if not cleaned up (only relevant when `stubTimers: false`)
216+
- [ ] Search for `process.on(` or `.addEventListener(` at module scope — listeners accumulate if added per render
217+
- [ ] Check third-party libraries for SSR cleanup functions (`resetServerContext`, `useStaticRendering`, etc.)
218+
- [ ] Verify `NODE_OPTIONS=--max-old-space-size=<MB>` is set in production
219+
- [ ] Verify `allWorkersRestartInterval` and `delayBetweenIndividualWorkerRestarts` are both configured

docs/pro/troubleshooting.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,21 @@ For issues related to upgrading from GitHub Packages to public distribution, see
2424

2525
### Workers crashing with memory leaks
2626

27-
**Symptom**: Node renderer workers restart frequently or OOM.
27+
**Symptom**: Node renderer workers restart frequently or OOM. Memory grows monotonically over time.
2828

29-
**Fixes**:
29+
**Root cause**: The Node Renderer reuses V8 VM contexts across requests. Any module-level state in your server bundle (caches, Sets, memoized functions) persists across all requests and can grow unboundedly. This is the most common cause of OOM in the Node Renderer.
30+
31+
**Immediate mitigations**:
32+
33+
- Set `NODE_OPTIONS=--max-old-space-size=<MB>` to cap V8 heap size and force more aggressive garbage collection
34+
- Enable rolling restarts with `allWorkersRestartInterval` and `delayBetweenIndividualWorkerRestarts` — these periodically kill and restart workers, reclaiming all accumulated memory
35+
36+
**Investigation**:
3037

31-
- Enable rolling restarts with `allWorkersRestartInterval` and `delayBetweenIndividualWorkerRestarts` — use high values to avoid all workers being down simultaneously
32-
- Profile memory using `node --inspect` (see [Profiling guide](./profiling-server-side-rendering-code.md))
33-
- Check for global state leaks and use `config.ssr_pre_hook_js` to clear them
38+
- Profile memory using `node --inspect` and heap snapshots (see [Profiling guide](./profiling-server-side-rendering-code.md))
39+
- Search your server bundle code for module-level `Map`, `Set`, `{}` caches, and `_.memoize` calls — these are the most common leak sources
40+
- Use `config.ssr_pre_hook_js` to run cleanup code before each render (e.g., clearing global state)
41+
- See the [Memory Leaks guide](./js-memory-leaks.md) for detailed patterns, an audit checklist, and fixes
3442

3543
### Workers killed during streaming
3644

llms.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ import registerServerComponent from 'react-on-rails-pro/registerServerComponent/
9999
import { wrapServerComponentRenderer } from 'react-on-rails-pro/wrapServerComponentRenderer/client';
100100
```
101101

102+
## SSR Memory Safety (Node Renderer)
103+
104+
IMPORTANT: The Node Renderer reuses V8 VM contexts across requests. Module-level mutable state persists for the worker's lifetime. When writing or modifying code that runs during SSR:
105+
106+
- NEVER use unbounded module-level caches (`const cache = {}`, `new Map()`, `new Set()`) — they grow with every unique request and cause OOM
107+
- NEVER use `_.memoize` at module scope for functions called with diverse SSR inputs
108+
- ALWAYS set `NODE_OPTIONS=--max-old-space-size=<MB>` in production containers
109+
- ALWAYS enable worker rolling restarts (`allWorkersRestartInterval` + `delayBetweenIndividualWorkerRestarts`)
110+
- React component state and hooks (`useState`, `useMemo`) are fine — React creates and discards them per render
111+
112+
See: https://www.shakacode.com/react-on-rails-pro/docs/js-memory-leaks/
113+
102114
## Documentation Links
103115

104116
- Docs: https://reactonrails.com/docs/

0 commit comments

Comments
 (0)