reports/ror-memory-leakage-investigation.md
Project: react_on_rails / react_on_rails_pro SSR Pipeline Issue: #3286 — Reproducible memory leak in react_on_rails_pro SSR pipeline Date: 2026-05-15 Status: Investigation complete — leak not reproducible
Users running SSR through react_on_rails_pro on Heroku-class dynos reported linear RSS growth of Rails workers under sustained traffic. The same workloads on older versions (react_on_rails 14.x + react_on_rails_pro 3.x) did not exhibit comparable growth.
We built an in-repo reproducer and ran extensive experiments (up to 30,000 requests over 68 minutes) to isolate the leak. We were unable to reproduce a classical heap memory leak in the SSR pipeline. Post-warmup RSS growth was 1.63 KB/req — well below the 15 KB/req threshold for "refuted."
Our conclusion is that the reported RSS growth is not caused by a simple heap-level object leak in the react_on_rails framework. Instead, it is consistent with memory fragmentation — a well-documented phenomenon in Ruby processes under sustained traffic with large, variable-size allocations. This type of growth is notoriously difficult to reproduce in synthetic benchmarks because it depends on specific allocator behavior, traffic patterns, payload diversity, and runtime conditions that are rare and hard to simulate deterministically.
We built a dedicated in-repo reproducer under react_on_rails_pro/spec/dummy/ that exercises the full SSR pipeline:
curl → Puma (Rails, production mode) → HTTPX (HTTP/2) → Node Renderer (Fastify) → renderToString → HTML
Key components:
LeakRepro.jsx) rendering complex nested datascript/leak_repro) with VmRSS sampling via /proc/<pid>/statusLEAK_REPRO=1): fragment caching, controller caching, prerender caching/proc/<puma_worker_pid>/status (VmRSS field)kb_per_req = (final_rss - warmup_rss) / (total - warmup_requests)| Band | Condition | Meaning |
|---|---|---|
| confirmed | kb_per_req >= 50 | Leak reproduces at primary target level |
| reduced | 15 <= kb_per_req < 50 | Partial improvement |
| refuted | kb_per_req < 15 | Leak effectively eliminated |
| inconclusive | Variance > 10% across 3 runs | Measurement too noisy |
Understanding the memory allocation path is essential to interpreting the results.
1. Rails controller serializes props as JSON (~80 KB to 4+ MB per request)
2. HTTPX client encodes props + bundle reference as multipart form data
3. HTTP/2 transport sends form to Node Renderer (Fastify on port 3800)
4. Node Renderer loads bundle into V8 VM context (reused across requests)
5. renderToString() produces HTML (100 KB to 2+ MB)
6. Response flows back: Renderer → HTTPX → Rails → Puma → client
Ruby side (Puma worker):
CONNECTION_MUTEXrenderer_http_pool_size (default: 10)Node side (Renderer worker):
manageVMPoolSize())renderingRequest cleared in finally block to avoid holding references# request.rb — HTTPX configuration
HTTPX.plugin(:h2c) # HTTP/2 cleartext
.plugin(:persistent) # Keep-alive connections
.plugin(:stream) # Streaming responses
.plugin(:retries, max_retries: 1,
retry_change_requests: true) # Retry on disconnect
The HTTP/2 multiplexing means a single connection handles many concurrent streams, each generating response buffers simultaneously across Puma threads.
| Parameter | Value |
|---|---|
| Items per page | 200 |
| Props size | ~80 KB JSON |
| Server bundle | ~2 MB |
| HTML output | ~100 KB |
| Requests | 500 |
Result: Post-warmup kb_per_req: 3–12 (across multiple runs)
Verdict: Refuted — no leak detected with small payloads.
| Parameter | Value |
|---|---|
| Items per page | 500 |
| Props size | ~4.3 MB JSON |
| Server bundle | ~55 MB |
| HTML output | ~2+ MB |
| Requests | 500 |
Results (3 runs):
| Metric | Run 1 | Run 2 | Run 3 |
|---|---|---|---|
| Baseline RSS (KB) | 228,580 | 248,528 | 258,396 |
| RSS after warmup (KB) | 664,216 | 475,472 | 573,116 |
| Final RSS (KB) | 562,680 | 550,972 | 576,580 |
| Post-warmup growth (KB) | -101,536 | 75,500 | 3,464 |
Post-warmup kb_per_req | -253.84 | 188.75 | 8.66 |
Verdict: Inconclusive — extreme variance due to GC sawtooth on large allocations. Run 1 showed negative growth (GC coincided with final sample). The 500-request window was too short.
| Parameter | Value |
|---|---|
| Items per page | 60 |
| Props size | ~510 KB JSON |
| Server bundle | ~55 MB |
| HTML output | ~250 KB |
| Requests | 30,000 |
| Warmup | 3,000 |
| Duration | ~68 minutes |
Results:
Baseline RSS: 170,448 KB (167 MB)
RSS after warmup (3K): 249,520 KB (244 MB)
Final RSS (30K): 293,612 KB (287 MB)
Post-warmup growth: 44,092 KB over 27,000 requests
Post-warmup kb_per_req: 1.63
RSS Trajectory Over 30K Requests:
RSS (MB)
325 |
320 | * * *
315 | * * * * ** * * * ** * * ** *
310 | * * * * ** * * * * * * * * * * * *
300 | * * ** ** * * * * * * * * *
295 | * * * * * * * * * * *
290 | * * * * *
285 | * * * * ** * *
280 | * * * *
275 | * *
250 | *
245 |****
+------------------------------------------------------------------------
0 3K 6K 9K 12K 15K 18K 21K 24K 27K 30K requests
Key observations:
Verdict: Refuted — 1.63 KB/req is well below the 15 KB/req threshold.
An earlier 30K attempt used a 5-minute renderer worker restart interval. At ~10,500 requests (~17 minutes), all 3 renderer workers restarted, causing HTTPX connection timeouts. This is a separate operational concern — connection pool recovery during worker restarts — not a memory leak.
The reported RSS growth is consistent with memory fragmentation, not a heap-level object leak. Memory fragmentation is a well-documented phenomenon in Ruby processes that manifests differently depending on:
Our reproducer uses deterministic data (Random.new(42) seed), uniform request patterns, and synthetic payloads — conditions that minimize fragmentation. Production traffic has none of these properties.
glibc's malloc creates per-thread memory arenas (up to cores * 8 on 64-bit Linux). Each arena is a 64 MB memory pool. Ruby's allocation pattern — many small, short-lived objects interleaved with occasional large allocations (multi-MB JSON, HTML responses) — causes these arenas to fragment internally:
The memory is free inside the arena, but the OS pages cannot be released because scattered live objects pin them. This is external fragmentation — RSS grows without any object leak.
HTTP/2 multiplexing means a single connection handles many concurrent streams. Each stream generates response buffers simultaneously across Puma threads. This creates:
Puma's fork-based cluster mode starts workers sharing the parent's memory pages. The first GC mark pass dirties heap bitmap pages, triggering copy-on-write. Subsequent allocations and GC cycles progressively copy the remaining shared pages. Workers diverge to near-full private copies, inflating per-worker RSS beyond what the live object set would suggest.
| Signal | Fragmentation | True Leak |
|---|---|---|
| RSS over time | Plateaus at 2–4x baseline | Grows without bound |
ObjectSpace.count_objects | Stable | Increasing |
GC.stat[:heap_live_slots] | Stable | Increasing |
| jemalloc switch | RSS drops 30–50% | No improvement |
| Worker restart | RSS resets to baseline | RSS resets (but re-leaks) |
| Synthetic reproducer | Cannot reproduce | Reproduces consistently |
Our 30K-request experiment showed the fragmentation pattern: a one-time warmup jump followed by a flat oscillation band with no upward drift.
The reproducer uses Random.new(42) to generate identical props for every request. In production:
The reproducer sends exactly 3 concurrent requests in lockstep. In production:
With large payloads (~4 MB props + ~2 MB HTML), Ruby's GC creates ±100 MB RSS swings per cycle. In our 500-request large-payload experiment, variance was so extreme that one run showed negative post-warmup growth. Fragmentation RSS growth (tens of MB over hours) is invisible under this noise.
Production environments have:
None of these are present in the reproducer.
While the react_on_rails framework itself does not leak, the persistent VM context architecture means application-level code can create leaks. The Node Renderer reuses V8 VM contexts across requests, so module-level mutable state persists for the lifetime of the worker process.
These are patterns found in application code (not in react_on_rails itself) that cause genuine Node-side leaks:
| Pattern | Mechanism | Severity |
|---|---|---|
Unbounded module-level caches (new Map(), {}) | Entries accumulate across requests, never evicted | High |
_.memoize() at module scope | Lodash memoize uses unbounded internal Map | Medium |
| Redux saga middleware reuse | sagaMiddleware.run() called per request on shared instance; watcher sagas never finish | High |
Module-level Set / array accumulation | Tracking sets grow with every unique input | Medium |
| Event listeners registered per render | process.on(...) adds duplicate listeners | Low |
| Third-party library caches | styled-components, Apollo, MobX internal state | Varies |
The VM context reuse is intentional for performance:
// vm.ts — Context pool management
vmContexts: Map<string, VMContext> // contexts indexed by bundle file path
lastUsed: Date.now() // LRU tracking for eviction
manageVMPoolSize() // evicts oldest context when pool exceeds max
The framework correctly:
context.renderingRequest in a finally block after each renderThe responsibility for avoiding module-level state accumulation lies with the application code.
jemalloc uses size-class segregated bins with bounded internal fragmentation (~20% vs glibc's unbounded arena growth). It aggressively returns freed pages to the OS via madvise(MADV_DONTNEED).
# In Dockerfile
RUN apt-get install -y libjemalloc2
ENV LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2
Expected impact: 30–50% RSS reduction for threaded Ruby programs.
export MALLOC_ARENA_MAX=2
This limits glibc to 2 arenas instead of cores * 8, reducing fragmentation at the cost of some lock contention.
NODE_OPTIONS=--max-old-space-size=1536 node renderer/node-renderer.js
Without this flag, V8 defers GC based on the container's full memory limit, amplifying any existing leaks.
const config = {
allWorkersRestartInterval: 45, // minutes
delayBetweenIndividualWorkerRestarts: 6, // minutes
gracefulWorkerRestartTimeout: 30, // seconds
};
Rolling restarts are the primary safety net — they periodically kill and restart workers, reclaiming all accumulated memory (both leaked and fragmented).
Users experiencing RSS growth should audit their server bundle for the patterns described in Section 6:
new Map(), new Set(), const cache = {} — are they unbounded?_.memoize at module scope — are they called with diverse SSR inputs?process.on( at module scope — listeners accumulate per renderSee Avoiding Memory Leaks in Node Renderer SSR for detailed guidance.
To distinguish fragmentation from true leaks:
GC.stat[:heap_live_slots] is stable but RSS grows, it's fragmentation--heapsnapshot-signal=SIGUSR2)| Requests | RSS (MB) | Delta (MB) | kb_per_req | Phase |
|---|---|---|---|---|
| 0 | 167 | 0 | 0.00 | Baseline |
| 300 | 235 | 68 | 233.37 | Warmup — heap expanding |
| 600 | 241 | 74 | 126.53 | Warmup — stabilizing |
| 3,000 | 244 | 77 | 26.36 | End of warmup |
| 6,000 | 237 | 71 | 12.05 | Steady state |
| 6,600 | 300 | 134 | 20.79 | GC spike |
| 7,500 | 275 | 108 | 14.81 | GC reclaimed |
| 10,500 | 271 | 104 | 10.18 | Steady state |
| 15,000 | 285 | 118 | 8.09 | Halfway — no upward trend |
| 18,900 | 270 | 103 | 5.59 | New low RSS |
| 20,100 | 314 | 148 | 7.54 | Highest spike (outlier) |
| 21,000 | 271 | 104 | 5.07 | GC reclaimed |
| 24,600 | 271 | 104 | 4.33 | Same trough |
| 28,200 | 275 | 108 | 3.94 | Same trough |
| 30,000 | 287 | 120 | 4.11 | Final |
| Metric | Value |
|---|---|
| Trough (GC low) | 268–283 MB |
| Peak (pre-GC high) | 308–322 MB |
| Band width | ~40–50 MB |
| Cycle length | ~600–900 requests (~3–5 min) |
| Peak drift over 27K requests | None |
| Trough drift over 27K requests | None |
| Component | Version/Config |
|---|---|
| Linux kernel | 6.8.0-111-generic |
| Ruby | 3.3.7 |
| Puma | 6.5.0 |
| Node.js | 20.x |
| HTTPX | Latest (HTTP/2 cleartext) |
| Puma workers | 1 (WEB_CONCURRENCY=1) |
| Puma threads | 3 (RAILS_MAX_THREADS=3) |
| Renderer workers | 3 |
| Caching | All disabled |
| File | Purpose |
|---|---|
client/app/components/LeakRepro.jsx | 12-subcomponent React page |
client/app/components/generate-leak-data.js | Generates ~50 MB data file |
client/app/ror-auto-load-components/LeakReproHashApp.server.jsx | SSR render function |
client/app/ror-auto-load-components/LeakReproHashApp.client.jsx | Client hydration stub |
app/controllers/pages_controller.rb | leak_repro action |
app/views/pages/leak_repro.html.erb | View with react_component_hash |
config/environments/production.rb | Cache disabling (LEAK_REPRO=1) |
config/initializers/react_on_rails_pro.rb | Prerender caching override |
renderer/node-renderer.js | Worker count/restart env vars |
script/leak_repro | Bash driver script |
MEMORY_LEAK_REPRO.md | Setup and usage docs |
MEMORY_LEAK_EXPERIMENTS.md | Full experiment log |