Streaming SSR Memory with renderToPipeableStream

Your React SSR server’s memory spikes with large pages under concurrency, you moved from renderToString to renderToPipeableStream expecting relief, and memory is better but still grows when clients disconnect mid-render or when a slow data source keeps Suspense boundaries pending. This guide from SSR Heap Exhaustion and Per-Request Memory, part of Node.js Server-Side Memory Management, explains how streaming changes per-request memory, and which three situations still hold renders in memory until you abort them.

Symptom Root Cause Immediate Action Measurable Impact
Peak memory scales with page size × concurrency renderToString builds the whole HTML string per request Stream with renderToPipeableStream Peak per request ≈ in-flight chunks, not the page
Memory grows when clients disconnect Render continues for a closed connection Call abort() on close Abandoned renders released
Requests hang and hold memory when a backend is slow Suspense boundaries wait indefinitely for data Abort after a timeout; server renders fallbacks Bounded request lifetime
Bots and crawlers use more memory per request onAllReady path buffers until everything resolves Keep onAllReady only where required; timeouts apply Bounded worst case
Large inline data payloads Serialised state for hydration embedded in HTML Serialise only what the client needs Smaller streamed output and retained data

Root Cause: Streaming Bounds Output, Not Pending Work

renderToString renders the whole tree to one string before sending anything. For a large page — long product lists, rich articles, dashboards — each concurrent request holds the complete HTML string plus the rendering state and data at the same time, so peak memory is roughly concurrency × (page size + data). It also waits for all data before sending a byte, lengthening how long each request’s memory lives.

renderToPipeableStream renders progressively: it emits the shell as soon as it is ready (onShellReady), then streams additional HTML as Suspense boundaries resolve, and pipe(res) writes into the response with backpressure, so the server does not build the full page in memory. For a typical page that reduces peak memory per request substantially and shortens time to first byte. The data each component needs still lives in memory while its part is rendering, but finished parts are flushed rather than accumulated.

Streaming does not bound pending work. A render stays alive — holding its component tree state, the data already fetched, the promises it waits on and the response object — until every Suspense boundary resolves or the render is aborted. Three situations keep renders alive far longer than intended. Disconnected clients: if the browser navigates away, the render continues unless you call abort() when the response closes. Slow or hanging data sources: a boundary waiting on a request that never completes keeps the render (and its captured data) alive; the retention mechanics are those of unsettled promises that leak their closures. The onAllReady path: waiting for everything before sending (common for crawlers or static generation) buffers the full output again, reintroducing renderToString-like peaks.

Calling abort() makes React stop waiting, render remaining boundaries’ fallbacks on the server so the client can take over, and release the render’s resources. A per-request timeout that aborts turns unbounded waits into a fixed worst case.

Per-request memory: three rendering modes renderToString holds memory rising until the full page string is built, then releases it after sending. Streaming without abort sends the shell quickly and flushes content, but a Suspense boundary waiting on a hanging backend keeps the request's memory alive indefinitely. Streaming with a ten second abort timeout releases memory at ten seconds, sending fallbacks for unresolved boundaries. high 0 abort at 10 s renderToString (full page) stream, hanging boundary, no abort stream + abort timeout time since request start

Step-by-Step Fix

  1. Measure per-request memory under concurrency. Load-test representative pages at realistic concurrency and record heap and RSS peaks. Verification: you have a baseline for renderToString or the current implementation.
  2. Switch to streaming. Use renderToPipeableStream, set status and headers in onShellReady, and call pipe(res) there. Verification: time to first byte drops and peak memory per request falls.
  3. Abort on disconnect. Listen for the response’s close event and call abort() if rendering has not finished. Verification: closing clients mid-render leaves no renders alive (heap returns to baseline after a disconnect-heavy test).
  4. Abort on timeout. Start a timer when rendering begins and call abort() after a budget (for example 10 seconds); clear it when rendering completes. Verification: with a hanging backend, requests end at the timeout and memory is released.
  5. Limit onAllReady use. Use it only where full HTML is required (crawlers, static export) and apply the same timeout. Verification: crawler traffic does not produce outsized memory peaks.
  6. Shrink hydration payloads. Serialise only data the client needs, not entire API responses. Verification: HTML size and per-request retained data fall.
Peak heap at 200 concurrent renders For a large product listing at 200 concurrent requests, renderToString peaks at about 1.1 gigabytes. Streaming peaks at about 420 megabytes in normal conditions but grows past 1.5 gigabytes during a backend slowdown without abort handling. Streaming with abort on close and a ten second timeout stays under about 480 megabytes even during the slowdown. Peak heap, 200 concurrent large-page renders renderToString ~1.1 GB stream, slow backend, no abort > 1.5 GB and rising stream + abort + timeout ~480 MB

Command and Code Reference

Use case: a streaming handler with abort on disconnect and timeout.

import { renderToPipeableStream } from 'react-dom/server';

const RENDER_TIMEOUT_MS = 10_000;

export function handle(req, res) {
  let finished = false;
  const { pipe, abort } = renderToPipeableStream(<App url={req.url} />, {
    bootstrapScripts: ['/client.js'],
    onShellReady() {
      res.statusCode = 200;
      res.setHeader('content-type', 'text/html; charset=utf-8');
      pipe(res);                                   // streams with backpressure
    },
    onShellError() {
      res.statusCode = 500;
      res.end('<!doctype html><p>Something went wrong</p>');
    },
    onAllReady() { finished = true; },
    onError(err) { console.error('ssr', err); },
  });

  const timer = setTimeout(() => abort(new Error('render timeout')), RENDER_TIMEOUT_MS);
  res.on('close', () => {
    clearTimeout(timer);
    if (!finished) abort();                        // client left: stop waiting, release memory
  });
}

Use case: pass abort signals into data fetching so aborted renders stop their requests.

// Create one AbortController per request and cancel it when the render aborts
export function createRequestFetch(signal) {
  return (url, init = {}) => fetch(url, { ...init, signal });
}
// In handle(): const controller = new AbortController();
//   res.on('close', () => controller.abort());
//   provide createRequestFetch(controller.signal) to data loaders via context

Verification and Regression Prevention

Verify three scenarios in load tests: normal traffic (peak memory well below the previous renderToString peak), disconnect-heavy traffic (clients closing mid-stream; heap returns to baseline afterwards), and a slow backend (requests end at the timeout, and memory plateaus instead of climbing). Heap snapshots taken after these tests should show no retained render state.

Keep the timeout and abort wiring in a shared SSR handler rather than in each route. Monitor render durations and aborts in production — a rising abort rate is an early sign of backend trouble — and alert on heap slope as described in alerting on memory leaks with growth slope. For per-request state that renders use, combine with request-scoped state with AsyncLocalStorage.

Three load-test scenarios for streaming SSR Test normal traffic, where peak memory should be well below the previous renderToString peak; disconnect-heavy traffic, where heap returns to baseline after clients close mid-stream; and a slow backend, where requests end at the timeout and memory plateaus instead of climbing. Load-test scenarios Normal traffic Peak well below the renderToString peak. Disconnect-heavy abort() on close; heap back to baseline. Slow backend Requests end at the timeout; memory plateaus.

Edge Cases and Gotchas

Compression middleware can buffer

Some compression middleware buffers output before compressing, which undoes streaming’s memory benefit and delays flushing. Use streaming-friendly compression and make sure it flushes after chunks.

Proxies that buffer responses

Reverse proxies may buffer the whole response before forwarding it, which does not affect the Node process’s memory but removes the latency benefit. Configure them to pass streamed responses through.

Errors after the shell

Once the shell is sent, the status code cannot change. Errors in later boundaries render fallbacks; log them and make sure they do not leave pending promises behind.

Framework-managed streaming

Meta-frameworks often use streaming internally and expose their own timeout settings. Check that aborts on client disconnect and render timeouts are configured rather than assuming defaults.

Frequently Asked Questions

Does renderToPipeableStream use less memory than renderToString?

Usually much less at peak, because it sends HTML as it is produced instead of building the whole page as one string. Data and component state still live in memory while rendering, but finished output is flushed.

Why does my streaming SSR server still leak memory?

Most often because renders are never aborted: clients disconnect and the render keeps waiting, or a Suspense boundary waits on a request that never finishes. Call abort() when the response closes and after a timeout.

What does abort() do?

It tells React to stop waiting for pending boundaries, render their fallbacks on the server so the client can retry them, and finish the stream. The render’s resources can then be released.

When should I use onAllReady instead of onShellReady?

When you need complete HTML before sending, such as for crawlers without JavaScript or static generation. It buffers more, so apply timeouts and use it only where needed.

How long should the render timeout be?

Long enough for normal slow paths and short enough to bound memory during incidents — often 5 to 15 seconds for user-facing pages. Base it on the slowest data sources you tolerate and your concurrency.

Do I need to abort data fetching too?

Ideally yes. Passing an AbortSignal to data requests lets an aborted render cancel its backend calls, so they do not keep running and retaining their results after the page is gone.

How do I measure per-request memory for streaming renders?

Run a load test at a fixed concurrency and record heap used and RSS during the steady state, then divide the increase over the idle baseline by the number of in-flight requests. Repeat at two concurrency levels: if memory grows linearly with concurrency, the slope is your per-request cost; if it keeps growing at constant concurrency, something outlives requests and needs a heap snapshot.

Does Suspense make memory usage worse?

Not by itself. Each pending Suspense boundary keeps its data promise and the partially rendered tree until it resolves or the render is aborted, so many slow boundaries per page increase what each request holds while it waits. Bounded timeouts, abort() on disconnect and data loaders scoped to the request keep that cost limited to the request’s lifetime.