Skip to main content

renderToReadableStream

renderToReadableStream renders a React tree to a ReadableStream of HTML using the Web Streams API. This is the recommended approach for edge runtimes, Cloudflare Workers, Deno, and other modern JavaScript environments.
const stream = await renderToReadableStream(element, options?);
This API uses Web Streams API (ReadableStream) instead of Node.js streams. For Node.js, use renderToPipeableStream instead.

Reference

renderToReadableStream(element, options?)

Renders a React element to a ReadableStream with full Suspense support and progressive streaming.
import { renderToReadableStream } from 'react-dom/server';

const stream = await renderToReadableStream(<App />);

Parameters

  • element: ReactNode - The React element to render
  • options: Object (optional)
    • identifierPrefix: string - Prefix for IDs generated by useId
    • namespaceURI: string - Namespace URI for the document (e.g., SVG)
    • nonce: string | { script?: string, style?: string } - Nonce for Content Security Policy
    • bootstrapScriptContent: string - Inline script to run before other scripts
    • bootstrapScripts: Array<string | { src: string, integrity?: string, crossOrigin?: string }> - External scripts to load
    • bootstrapModules: Array<string | { src: string, integrity?: string, crossOrigin?: string }> - External modules to load
    • progressiveChunkSize: number - Size of chunks for progressive streaming
    • signal: AbortSignal - Signal to abort the render
    • onError: (error: mixed, errorInfo: ErrorInfo) => ?string - Error handler callback
    • importMap: ImportMap - Import map for module scripts
    • formState: ReactFormState | null - Form state for progressive enhancement
    • onHeaders: (headers: Headers) => void - Callback when headers are ready
    • maxHeadersLength: number - Maximum length for early hints headers

Returns

Returns a Promise that resolves to a ReactDOMServerReadableStream:
type ReactDOMServerReadableStream = ReadableStream & {
  allReady: Promise<void>; // Resolves when all content is ready
};
  • The Promise resolves when the shell (initial UI) is ready
  • The stream continues emitting chunks as Suspense boundaries resolve
  • stream.allReady resolves when all content, including Suspense boundaries, is complete

Caveats

  • Async API: Returns a Promise that must be awaited
  • Edge optimized: Designed for edge runtimes using Web Streams
  • No pipe method: Use the stream directly with Response or other Web APIs
  • One-time use: The stream can only be read once

Usage

Basic streaming with edge runtime

import { renderToReadableStream } from 'react-dom/server';
import App from './App';

export const config = {
  runtime: 'edge',
};

export default async function handler(request) {
  const stream = await renderToReadableStream(<App />, {
    bootstrapScripts: ['/client.js'],
  });
  
  return new Response(stream, {
    headers: { 'Content-Type': 'text/html' },
  });
}

Streaming with Suspense

import { Suspense } from 'react';
import { renderToReadableStream } from 'react-dom/server';

function App() {
  return (
    <html>
      <head>
        <title>My App</title>
      </head>
      <body>
        <nav>
          <a href="/">Home</a>
        </nav>
        <main>
          {/* Shell content - sent immediately */}
          <h1>Welcome</h1>
          
          {/* Streamed when ready */}
          <Suspense fallback={<div>Loading posts...</div>}>
            <BlogPosts />
          </Suspense>
          
          <Suspense fallback={<div>Loading comments...</div>}>
            <Comments />
          </Suspense>
        </main>
      </body>
    </html>
  );
}

// BlogPosts is an async server component
async function BlogPosts() {
  const posts = await fetchPosts();
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

export default async function handler() {
  const stream = await renderToReadableStream(<App />);
  return new Response(stream, {
    headers: { 'Content-Type': 'text/html' },
  });
}

Error handling

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

export default async function handler(request) {
  try {
    const stream = await renderToReadableStream(<App />, {
      bootstrapScripts: ['/client.js'],
      onError(error, errorInfo) {
        // Log errors to your error tracking service
        console.error('Rendering error:', error);
        console.error('Component stack:', errorInfo.componentStack);
        
        // Return an error digest to send to the client
        // This will be available in Error Boundaries
        return errorInfo.digest || 'UNKNOWN_ERROR';
      },
    });
    
    return new Response(stream, {
      status: 200,
      headers: { 'Content-Type': 'text/html' },
    });
  } catch (error) {
    // Shell rendering failed - return error page
    console.error('Fatal rendering error:', error);
    
    return new Response(
      '<html><body><h1>Something went wrong</h1></body></html>',
      {
        status: 500,
        headers: { 'Content-Type': 'text/html' },
      }
    );
  }
}

Aborting renders

Use AbortSignal to cancel rendering when the client disconnects:
import { renderToReadableStream } from 'react-dom/server';

export default async function handler(request) {
  // Create an abort controller
  const controller = new AbortController();
  
  // Abort if client disconnects
  request.signal.addEventListener('abort', () => {
    controller.abort();
  });
  
  // Set a timeout
  const timeout = setTimeout(() => {
    controller.abort();
  }, 10000); // 10 second timeout
  
  try {
    const stream = await renderToReadableStream(<App />, {
      signal: controller.signal,
      onError(error) {
        if (error.name === 'AbortError') {
          console.log('Render aborted');
        } else {
          console.error('Render error:', error);
        }
      },
    });
    
    clearTimeout(timeout);
    
    return new Response(stream, {
      headers: { 'Content-Type': 'text/html' },
    });
  } catch (error) {
    clearTimeout(timeout);
    throw error;
  }
}

Waiting for all content (SEO)

Wait for allReady when serving content to search engine crawlers:
import { renderToReadableStream } from 'react-dom/server';

function isCrawler(userAgent) {
  return /bot|crawler|spider|crawling/i.test(userAgent);
}

export default async function handler(request) {
  const stream = await renderToReadableStream(<App />, {
    bootstrapScripts: ['/client.js'],
  });
  
  const userAgent = request.headers.get('User-Agent') || '';
  
  if (isCrawler(userAgent)) {
    // Wait for all Suspense boundaries to resolve
    await stream.allReady;
  }
  
  return new Response(stream, {
    headers: { 'Content-Type': 'text/html' },
  });
}

Custom headers with onHeaders

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

export default async function handler(request) {
  const responseHeaders = new Headers();
  
  const stream = await renderToReadableStream(<App />, {
    onHeaders(headers) {
      // Merge preload hints into response headers
      headers.forEach((value, key) => {
        responseHeaders.append(key, value);
      });
    },
  });
  
  responseHeaders.set('Content-Type', 'text/html');
  
  return new Response(stream, {
    headers: responseHeaders,
  });
}

Advanced Patterns

Streaming with transformations

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

export default async function handler(request) {
  const stream = await renderToReadableStream(<App />);
  
  // Transform the stream (e.g., inject analytics)
  const transformedStream = stream.pipeThrough(
    new TransformStream({
      transform(chunk, controller) {
        const text = new TextDecoder().decode(chunk);
        
        // Inject script before </body>
        const transformed = text.replace(
          '</body>',
          '<script src="/analytics.js"></script></body>'
        );
        
        controller.enqueue(new TextEncoder().encode(transformed));
      },
    })
  );
  
  return new Response(transformedStream, {
    headers: { 'Content-Type': 'text/html' },
  });
}

Streaming with compression

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

export default async function handler(request) {
  const stream = await renderToReadableStream(<App />);
  
  // Check if client accepts compression
  const acceptEncoding = request.headers.get('Accept-Encoding') || '';
  
  if (acceptEncoding.includes('gzip')) {
    // Compress the stream
    const compressedStream = stream.pipeThrough(
      new CompressionStream('gzip')
    );
    
    return new Response(compressedStream, {
      headers: {
        'Content-Type': 'text/html',
        'Content-Encoding': 'gzip',
      },
    });
  }
  
  return new Response(stream, {
    headers: { 'Content-Type': 'text/html' },
  });
}

Streaming HTML with document wrapper

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

class HTMLStream {
  static async create(element, options = {}) {
    const { title = 'My App', ...streamOptions } = options;
    
    // Render just the app content
    const appStream = await renderToReadableStream(element, streamOptions);
    
    // Create a composite stream with HTML wrapper
    const { readable, writable } = new TransformStream();
    const writer = writable.getWriter();
    
    // Write HTML opening
    await writer.write(
      new TextEncoder().encode(
        `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${title}</title></head><body><div id="root">`
      )
    );
    
    // Pipe app content
    appStream.pipeTo(writable, { preventClose: true }).then(async () => {
      // Write HTML closing
      await writer.write(
        new TextEncoder().encode('</div></body></html>')
      );
      await writer.close();
    });
    
    return readable;
  }
}

// Usage
export default async function handler() {
  const stream = await HTMLStream.create(<App />, {
    title: 'My Streaming App',
    bootstrapScripts: ['/client.js'],
  });
  
  return new Response(stream, {
    headers: { 'Content-Type': 'text/html' },
  });
}

Runtime Differences

// react-dom/server.edge
import { renderToReadableStream } from 'react-dom/server.edge';

// Uses native Web Streams (ReadableStreamController)
const stream = await renderToReadableStream(<App />);
// Returns: ReadableStream with direct controller access
Optimized for:
  • Vercel Edge Functions
  • Cloudflare Workers
  • Deno Deploy
  • Edge runtimes without Node.js APIs

Common Issues

ReadableStream can only be read once. Don’t try to use the same stream multiple times:
const stream = await renderToReadableStream(<App />);

// This works
return new Response(stream);

// This will fail - stream already consumed
const clone = stream.tee(); // Error!
Solution: Generate a new stream for each request.
When using onHeaders, ensure you’re setting headers before creating the Response:
// Wrong - response already created
const response = new Response(stream);
onHeaders((headers) => {
  response.headers.set('Link', headers.get('Link')); // Too late!
});

// Correct - collect headers first
const responseHeaders = new Headers();
const stream = await renderToReadableStream(<App />, {
  onHeaders(headers) {
    headers.forEach((value, key) => {
      responseHeaders.append(key, value);
    });
  },
});
return new Response(stream, { headers: responseHeaders });
If rendering fails before the shell is ready, the Promise rejects:
try {
  const stream = await renderToReadableStream(<App />);
  return new Response(stream);
} catch (error) {
  // Shell failed to render - show error page
  return new Response('<h1>Error</h1>', { status: 500 });
}
If a Suspense boundary never resolves, allReady will hang:
// This will hang forever
function NeverResolves() {
  throw new Promise(() => {}); // Never resolves!
}

<Suspense fallback={<div>Loading...</div>}>
  <NeverResolves />
</Suspense>
Solution: Add timeouts and proper error handling for async operations.

Performance Best Practices

Start streaming as soon as the shell is ready:
// Good - stream starts immediately
const stream = await renderToReadableStream(<App />);
return new Response(stream);

// Bad - waiting for allReady delays TTFB
const stream = await renderToReadableStream(<App />);
await stream.allReady; // Don't do this for users!
return new Response(stream);
Control streaming granularity with progressiveChunkSize:
const stream = await renderToReadableStream(<App />, {
  progressiveChunkSize: 2048, // bytes
});
Smaller = more granular streaming, larger = fewer chunks
Keep Suspense boundaries small and focused:
// Bad - entire page waits for slow data
<Suspense fallback={<PageSpinner />}>
  <EntirePage /> {/* Includes slow data */}
</Suspense>

// Good - only slow parts wait
<Header /> {/* Streamed immediately */}
<Suspense fallback={<Spinner />}>
  <SlowData /> {/* Only this waits */}
</Suspense>
<Footer /> {/* Streamed immediately */}

See Also