JavaScript and React course Β· Module 10: React Ecosystem and Future

Streaming SSR and Edge Runtime

9 min read
In this lesson8

Imagine that instead of sending the entire spaceship in one transport, you send it module by module - the cockpit immediately, while navigation systems and cargo bays arrive in the background. That is exactly how Streaming SSR works - the server starts sending HTML to the browser before the entire rendering is complete. And Edge Runtime moves this process closer to the user - like relay stations distributed across the galaxy.

Traditional SSR vs Streaming SSR

Traditional SSR generates the entire HTML on the server before sending it to the browser. The user waits for the slowest component:

1// Traditional SSR - user waits for EVERYTHING
2// Server: [render Header][render Data][render Footer] -> send everything
3// Time: 500ms + 2000ms + 100ms = 2600ms before they see anything
4
5// Streaming SSR - fragments sent immediately
6// Server: [Header ready] -> send! [Data ready] -> send! [Footer] -> send!
7// Time: 500ms to first fragment, rest arrives in background

renderToPipeableStream - Streaming API

React 18 introduced renderToPipeableStream, the streaming successor of renderToString, which had to render the whole page before sending the first byte:

1import { renderToPipeableStream } from 'react-dom/server';
2
3// Node.js server with Streaming SSR
4app.get('/', (req, res) => {
5  const { pipe } = renderToPipeableStream(
6    <App />,
7    {
8      bootstrapScripts: ['/main.js'],
9      onShellReady() {
10        // Shell is ready - send immediately
11        res.setHeader('Content-Type', 'text/html');
12        pipe(res);
13      },
14      onShellError(error) {
15        // Error in critical part - send fallback
16        res.statusCode = 500;
17        res.send('<h1>Server Error</h1>');
18      },
19      onAllReady() {
20        // Everything ready (useful for crawlers/SEO)
21      },
22      onError(error) {
23        console.error('Streaming error:', error);
24      }
25    }
26  );
27});

onShellReady is called when the main "shell" of the page is ready - everything outside <Suspense> boundaries. The user sees the page skeleton immediately.

renderToReadableStream - Edge Runtime API

For Edge Runtime (Cloudflare Workers, Vercel Edge, Deno Deploy) we use renderToReadableStream, which returns a Web Streams API:

1import { renderToReadableStream } from 'react-dom/server';
2
3// Edge Runtime handler
4export default async function handler(request) {
5  const stream = await renderToReadableStream(
6    <App />,
7    {
8      bootstrapScripts: ['/main.js'],
9      onError(error) {
10        console.error('Edge streaming error:', error);
11      }
12    }
13  );
14
15  return new Response(stream, {
16    headers: { 'Content-Type': 'text/html' },
17  });
18}

Difference between APIs:

  • renderToPipeableStream - Node.js Streams (Node.js runtime)
  • renderToReadableStream - Web Streams API (Edge Runtime, Deno, browsers)

Since React 19.2 renderToReadableStream also works in Node.js, but the React team still recommends renderToPipeableStream there: Node.js streams are faster in Node, and Web Streams do not support compression by default.

Suspense as Streaming Boundaries

<Suspense> defines streaming boundaries - the server sends a fallback and later replaces it with the ready HTML:

1function SpaceStationDashboard() {
2  return (
3    <div>
4      {/* Rendered immediately - part of "shell" */}
5      <Header title="Space Station Alpha" />
6      <Navigation />
7
8      {/* Each section streams independently */}
9      <Suspense fallback={<Skeleton type="missions" />}>
10        <MissionList />  {/* Data loads in 800ms */}
11      </Suspense>
12
13      <Suspense fallback={<Skeleton type="crew" />}>
14        <CrewStatus />   {/* Data loads in 1500ms */}
15      </Suspense>
16
17      <Suspense fallback={<Skeleton type="telemetry" />}>
18        <TelemetryData />  {/* Data loads in 2200ms */}
19      </Suspense>
20    </div>
21  );
22}
23
24// Streaming flow:
25// 1. [0ms]    Server sends: Header + Navigation + 3x Skeleton
26// 2. [800ms]  Server streams: MissionList (replaces Skeleton)
27// 3. [1500ms] Server streams: CrewStatus (replaces Skeleton)
28// 4. [2200ms] Server streams: TelemetryData (replaces Skeleton)

Errors and Data Inside Suspense Boundaries

Suspense only handles waiting for data. If fetching fails, the component throws an error, and the nearest Error Boundary catches it. That is why a slow section gets wrapped in two layers: an Error Boundary outside and Suspense inside. In such a component you can conveniently fetch data with the useSuspenseQuery hook from TanStack Query, which suspends the component until the data arrives:

1import { Suspense } from 'react';
2import { ErrorBoundary } from 'react-error-boundary';
3import { useSuspenseQuery } from '@tanstack/react-query';
4
5function MissionList() {
6  // data is always defined: Suspense handles loading and the Error Boundary handles errors
7  const { data } = useSuspenseQuery({ queryKey: ['missions'], queryFn: fetchMissions });
8
9  return (
10    <ul>
11      {data.map(mission => (
12        <li key={mission.id}>{mission.name}</li>
13      ))}
14    </ul>
15  );
16}
17
18function MissionsSection() {
19  return (
20    <ErrorBoundary fallback={<MissionsError />}>
21      <Suspense fallback={<Skeleton type="missions" />}>
22        <MissionList />
23      </Suspense>
24    </ErrorBoundary>
25  );
26}

While loading you see the skeleton from Suspense, and when the query fails, the ErrorBoundary from the react-error-boundary package shows MissionsError in place of the whole section. On the server it works in a similar way: if a component inside a Suspense boundary throws an error during streaming, React does not abort the response, it sends that boundary's fallback and retries rendering in the browser. Only when that fails too does the error reach the nearest Error Boundary. An error in the shell itself, outside all boundaries, triggers onShellError.

Edge Runtime - Computations Closer to the User

Edge Runtime is a lightweight execution environment running on the "edge" of the network, on CDN servers spread around the world, close to the user. Instead of full Node.js it runs code in isolates of the V8 engine (the same engine that runs JavaScript in Chrome) and gives it Web APIs: fetch, Request, Response and Web Streams. An isolate starts roughly a hundred times faster than a Node.js process, which is why a cold start here takes just a few milliseconds. This is what React streaming looks like in Cloudflare Workers:

1// worker.jsx - Cloudflare Workers (V8 isolates, Web APIs instead of Node.js)
2import { renderToReadableStream } from 'react-dom/server';
3import App from './App';
4
5export default {
6  async fetch(request) {
7    // Cloudflare attaches the user's location data to the request
8    const city = request.cf?.city ?? 'unknown city';
9
10    const stream = await renderToReadableStream(<App city={city} />, {
11      bootstrapScripts: ['/main.js'],
12    });
13
14    return new Response(stream, {
15      headers: { 'Content-Type': 'text/html; charset=utf-8' },
16    });
17  },
18};

The worker has no access to the file system or to Node.js modules, but it responds from the server closest to the user. The location comes from the platform here, through request.cf, not from React. In Next.js the request.geo object was removed in version 15, and on Vercel you read the city with the geolocation() function from the @vercel/functions package.

Differences: Node.js vs Edge Runtime

The table shows what you pay for an instant start at the edge of the network. Cold start times are indicative and depend on the platform:

FeatureNode.js RuntimeEdge Runtime
LocationOne or several regionsCDN servers around the world
Cold start200-500ms (cold serverless function)1-5ms (V8 isolate)
Code sizeLarge limit (e.g. 250 MB on Vercel)1-4 MB after compression (Vercel, depending on the plan)
APIFull Node.js APIWeb APIs, no file system or Node.js modules
StreamingrenderToPipeableStreamrenderToReadableStream
DatabasesAnyDrivers that work over HTTP (e.g. Turso, Neon)

Edge Runtime in Next.js 16

The fast-start advantage is shrinking, because platforms also shorten Node.js cold starts. Fluid compute on Vercel caches compiled bytecode and pre-warms functions in production, which is why Vercel now recommends moving functions from Edge to Node.js. Next.js went the same way: in version 16 the middleware.ts file was renamed to proxy.ts and always runs on Node.js, and in version 16.3 the runtime = 'edge' export in page and route handler files became deprecated. Next.js warns about it and advises removing it, because the default Node.js runtime needs no configuration, and Vercel runs such routes on Node.js:

1// proxy.js (Next.js 16, formerly middleware.js) - always the Node.js runtime
2import { NextResponse } from 'next/server';
3
4export function proxy(request) {
5  const isDashboard = request.nextUrl.pathname.startsWith('/dashboard');
6
7  // Without a session, redirect to login before the page starts rendering
8  if (isDashboard && !request.cookies.has('session')) {
9    return NextResponse.redirect(new URL('/login', request.url));
10  }
11  return NextResponse.next();
12}

Proxy runs before every route is rendered, so in a real project narrow it down with the matcher option in the config object. Edge Runtime is not disappearing from the ecosystem, though: it remains the natural environment of platforms built on isolates, such as Cloudflare Workers or Deno Deploy, and renderToReadableStream works there unchanged.

Progressive Hydration

Streaming SSR naturally supports progressive hydration - React hydrates components as they appear:

1// Progressive hydration flow:
2// 1. Server streams HTML -> browser displays
3// 2. JavaScript loads
4// 3. React hydrates components already in DOM
5// 4. New HTML fragments arrive from stream
6// 5. React hydrates them immediately upon receipt
7
8// Selective hydration - React prioritizes
9// components the user interacts with:
10function App() {
11  return (
12    <div>
13      {/* User clicked here - React hydrates this FIRST */}
14      <Suspense fallback={<Skeleton />}>
15        <InteractivePanel />
16      </Suspense>
17
18      {/* This waits in queue */}
19      <Suspense fallback={<Skeleton />}>
20        <HeavyDataTable />
21      </Suspense>
22    </div>
23  );
24}

Summary

Streaming SSR and Edge Runtime are powerful tools for accelerating application delivery:

  1. Streaming SSR - sends HTML in fragments, user sees the page immediately
  2. renderToPipeableStream - Node.js API for streaming
  3. renderToReadableStream - Web Streams API (Edge Runtime, Cloudflare Workers, Deno)
  4. Suspense boundaries - define streaming boundaries, and an Error Boundary above them catches errors
  5. Edge Runtime - code close to the user and a fast cold start, but in Next.js 16 the default and recommended environment is Node.js
  6. Progressive Hydration - React hydrates components as they appear

Streaming SSR changes application delivery like modular systems on a space station - each module launches independently, the user does not wait for the whole thing. In the example below the sections arrive in the order their data becomes ready, and after you tick the relay failure and send a new request, you will see the Error Boundary catch one section's error while the rest of the page keeps working.

Code for this lesson: App.jsx
1import { Component, Suspense, use, useCallback, useEffect, useState } from 'react';
2
3// === STREAMING SSR - a simulation in the browser ===
4// The shell (header and layout) appears immediately, and each Suspense boundary
5// shows a skeleton until its data arrives. Sections arrive in the order their
6// data becomes ready, not in their order on the page.
7
8const DATA = {
9  missions: [
10    { id: 1, name: 'Mars Expedition', status: 'active', eta: '47 days' },
11    { id: 2, name: 'Europa Probe', status: 'planned', eta: '182 days' },
12    { id: 3, name: 'Lunar Base', status: 'completed', eta: '-' },
13  ],
14  crew: [
15    { id: 1, name: 'Commander Nova', role: 'Mission lead', ready: true },
16    { id: 2, name: 'Astro', role: 'Navigator', ready: true },
17    { id: 3, name: 'Ra', role: 'Guide', ready: false },
18  ],
19  telemetry: [
20    { label: 'Oxygen', value: 96 },
21    { label: 'Fuel', value: 78 },
22    { label: 'Hull', value: 99 },
23    { label: 'Comms', value: 87 },
24  ],
25  earth: 'Earth Control confirms the course. Good luck, crew!',
26};
27
28const DELAYS = { missions: 600, earth: 900, crew: 1200, telemetry: 2000 };
29
30// One promise per section in a given request. The same promise on the next
31// render lets the use() hook read the ready data.
32const requests = new Map();
33
34// When the current request started (for the timeline)
35let requestStart = performance.now();
36
37function loadSection(section, requestKey, fail = false) {
38  const key = section + ':' + requestKey;
39  if (!requests.has(key)) {
40    requests.set(key, new Promise((resolve, reject) => {
41      setTimeout(() => {
42        if (fail) reject(new Error('No signal from the orbital relay'));
43        else resolve(DATA[section]);
44      }, DELAYS[section]);
45    }));
46  }
47  return requests.get(key);
48}
49
50// Error Boundary: catches an error thrown while a section renders
51class RelayBoundary extends Component {
52  constructor(props) {
53    super(props);
54    this.state = { error: null };
55  }
56
57  static getDerivedStateFromError(error) {
58    return { error };
59  }
60
61  componentDidCatch(error) {
62    this.props.onError(error.message);
63  }
64
65  render() {
66    if (this.state.error) {
67      return (
68        <div style={styles.errorBox}>
69          <strong>Connection lost</strong>
70          <p style={{ margin: '4px 0 8px' }}>{this.state.error.message}</p>
71          <button
72            style={styles.smallButton}
73            onClick={() => {
74              this.props.onRetry();
75              this.setState({ error: null });
76            }}
77          >
78            Try again
79          </button>
80        </div>
81      );
82    }
83    return this.props.children;
84  }
85}
86
87function Skeleton({ label }) {
88  return (
89    <div style={styles.skeleton}>
90      <div style={styles.shimmer} />
91      <p style={styles.skeletonLabel}>Waiting for section: {label}...</p>
92    </div>
93  );
94}
95
96function MissionPanel({ requestId, onReady }) {
97  const missions = use(loadSection('missions', requestId));
98  useEffect(() => {
99    onReady('Missions', '#00ff88');
100  }, [onReady]);
101
102  return (
103    <div style={styles.panel}>
104      <h3 style={styles.panelTitle}>Missions (600 ms)</h3>
105      {missions.map(m => (
106        <div key={m.id} style={styles.item}>
107          <span style={styles.badge}>{m.status}</span>
108          <strong>{m.name}</strong>
109          <span style={styles.muted}>ETA: {m.eta}</span>
110        </div>
111      ))}
112    </div>
113  );
114}
115
116function CrewPanel({ requestId, onReady }) {
117  const crew = use(loadSection('crew', requestId));
118  useEffect(() => {
119    onReady('Crew', '#ffaa00');
120  }, [onReady]);
121
122  return (
123    <div style={styles.panel}>
124      <h3 style={styles.panelTitle}>Crew (1200 ms)</h3>
125      {crew.map(c => (
126        <div key={c.id} style={styles.item}>
127          <span style={{ ...styles.dot, background: c.ready ? '#00ff88' : '#ff6b6b' }} />
128          <strong>{c.name}</strong>
129          <span style={styles.muted}>{c.role}</span>
130        </div>
131      ))}
132    </div>
133  );
134}
135
136function TelemetryPanel({ requestId, onReady }) {
137  const telemetry = use(loadSection('telemetry', requestId));
138  useEffect(() => {
139    onReady('Telemetry', '#ff6b6b');
140  }, [onReady]);
141
142  return (
143    <div style={styles.panel}>
144      <h3 style={styles.panelTitle}>Telemetry (2000 ms)</h3>
145      <div style={styles.telGrid}>
146        {telemetry.map(t => (
147          <div key={t.label} style={styles.telItem}>
148            <span style={styles.telLabel}>{t.label}</span>
149            <div style={styles.bar}>
150              <div style={{ ...styles.barFill, width: t.value + '%' }} />
151            </div>
152            <span>{t.value}%</span>
153          </div>
154        ))}
155      </div>
156    </div>
157  );
158}
159
160function EarthPanel({ requestKey, fail, label, onReady }) {
161  const message = use(loadSection('earth', requestKey, fail));
162  useEffect(() => {
163    onReady(label, '#00d4ff');
164  }, [onReady, label]);
165
166  return (
167    <div style={styles.panel}>
168      <h3 style={styles.panelTitle}>Contact with Earth (900 ms)</h3>
169      <p style={{ margin: 0, fontSize: '12px' }}>{message}</p>
170    </div>
171  );
172}
173
174export default function App() {
175  const [request, setRequest] = useState({ id: 1, fail: false });
176  const [failNext, setFailNext] = useState(false);
177  const [attempt, setAttempt] = useState(0);
178  const [events, setEvents] = useState([{ label: 'Shell (header and layout)', ms: 0, color: '#8892b0' }]);
179  // A section reports itself after rendering, so the timeline shows the real order
180  const report = useCallback((label, color) => {
181    const ms = Math.round(performance.now() - requestStart);
182    setEvents(prev => (prev.some(e => e.label === label) ? prev : [...prev, { label, ms, color }]));
183  }, []);
184
185  const reportError = useCallback(message => {
186    report('Connection error: ' + message, '#ff6b6b');
187  }, [report]);
188
189  const startRequest = () => {
190    requestStart = performance.now();
191    setAttempt(0);
192    setEvents([{ label: 'Shell (header and layout)', ms: 0, color: '#8892b0' }]);
193    setRequest(r => ({ id: r.id + 1, fail: failNext }));
194  };
195
196  const earthFails = request.fail && attempt === 0;
197  const earthLabel = attempt === 0 ? 'Contact with Earth' : 'Contact with Earth (retry)';
198
199  return (
200    <div style={styles.container}>
201      <header style={styles.header}>
202        <h1 style={styles.title}>Streaming SSR - simulation</h1>
203        <p style={styles.subtitle}>
204          The shell appears immediately, and each Suspense boundary streams in its section when its data is ready
205        </p>
206        <div style={styles.toolbar}>
207          <button style={styles.button} onClick={startRequest}>New request</button>
208          <label style={styles.checkbox}>
209            <input type="checkbox" checked={failNext} onChange={e => setFailNext(e.target.checked)} />
210            Relay failure in the next request
211          </label>
212        </div>
213      </header>
214
215      <section style={styles.shell}>
216        <h2 style={{ color: '#00d4ff', margin: 0, fontSize: '16px' }}>Station Alpha - Dashboard (request #{request.id})</h2>
217        <p style={{ color: '#8892b0', margin: '2px 0 0', fontSize: '11px' }}>Shell: sent immediately (0 ms)</p>
218      </section>
219
220      <div style={styles.mainGrid}>
221        <div key={request.id} style={styles.grid}>
222          <Suspense fallback={<Skeleton label="Missions" />}>
223            <MissionPanel requestId={request.id} onReady={report} />
224          </Suspense>
225
226          <RelayBoundary onError={reportError} onRetry={() => setAttempt(a => a + 1)}>
227            <Suspense fallback={<Skeleton label="Contact with Earth" />}>
228              <EarthPanel
229                requestKey={request.id + '-' + attempt}
230                fail={earthFails}
231                label={earthLabel}
232                onReady={report}
233              />
234            </Suspense>
235          </RelayBoundary>
236
237          <Suspense fallback={<Skeleton label="Crew" />}>
238            <CrewPanel requestId={request.id} onReady={report} />
239          </Suspense>
240
241          <Suspense fallback={<Skeleton label="Telemetry" />}>
242            <TelemetryPanel requestId={request.id} onReady={report} />
243          </Suspense>
244        </div>
245
246        <aside style={styles.timeline}>
247          <h3 style={styles.panelTitle}>Streaming order</h3>
248          {events.map(e => (
249            <div key={e.label} style={styles.timelineItem}>
250              <span style={{ ...styles.dot, background: e.color }} />
251              <span style={styles.time}>{e.ms} ms</span>
252              <span>{e.label}</span>
253            </div>
254          ))}
255        </aside>
256      </div>
257    </div>
258  );
259}
260
261const styles = {
262  container: { fontFamily: 'system-ui, sans-serif', background: '#0a0e17', color: '#e0e1dd', minHeight: '100vh', padding: '14px' },
263  header: { textAlign: 'center', marginBottom: '12px' },
264  title: { fontSize: '20px', color: '#00d4ff', margin: '0 0 4px' },
265  subtitle: { fontSize: '12px', color: '#8892b0', margin: '0 0 10px' },
266  toolbar: { display: 'flex', gap: '12px', justifyContent: 'center', alignItems: 'center', flexWrap: 'wrap' },
267  button: { padding: '6px 14px', background: '#00d4ff', color: '#0a0e17', border: 'none', borderRadius: '6px', fontWeight: 'bold', cursor: 'pointer' },
268  smallButton: { padding: '4px 10px', background: 'transparent', color: '#ff6b6b', border: '1px solid #ff6b6b', borderRadius: '6px', cursor: 'pointer', fontSize: '11px' },
269  checkbox: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: '#8892b0' },
270  shell: { background: 'rgba(0,212,255,0.08)', border: '1px solid rgba(0,212,255,0.2)', borderRadius: '8px', padding: '10px 12px', marginBottom: '12px' },
271  mainGrid: { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 210px', gap: '12px' },
272  grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '10px', alignContent: 'start' },
273  panel: { background: 'rgba(0,212,255,0.05)', borderRadius: '8px', padding: '12px', border: '1px solid rgba(0,212,255,0.15)' },
274  panelTitle: { fontSize: '13px', color: '#00d4ff', margin: '0 0 8px' },
275  item: { display: 'flex', alignItems: 'center', gap: '6px', padding: '5px 8px', background: 'rgba(0,0,0,0.3)', borderRadius: '4px', marginBottom: '4px', fontSize: '11px' },
276  badge: { padding: '1px 6px', borderRadius: '6px', fontSize: '9px', background: 'rgba(0,255,136,0.15)', color: '#00ff88' },
277  muted: { marginLeft: 'auto', color: '#8892b0', fontSize: '10px' },
278  dot: { width: '8px', height: '8px', borderRadius: '50%', flexShrink: 0, display: 'inline-block' },
279  skeleton: { minHeight: '110px', background: 'rgba(0,212,255,0.03)', border: '1px dashed rgba(0,212,255,0.2)', borderRadius: '8px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' },
280  shimmer: { width: '50%', height: '8px', background: 'rgba(0,212,255,0.12)', borderRadius: '4px', marginBottom: '6px' },
281  skeletonLabel: { color: '#8892b0', fontSize: '11px', margin: 0 },
282  errorBox: { minHeight: '110px', background: 'rgba(255,107,107,0.08)', border: '1px solid rgba(255,107,107,0.4)', borderRadius: '8px', padding: '12px', fontSize: '12px', color: '#ffb3b3' },
283  telGrid: { display: 'grid', gap: '6px' },
284  telItem: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px' },
285  telLabel: { color: '#8892b0', width: '60px' },
286  bar: { flex: 1, height: '8px', background: 'rgba(255,255,255,0.08)', borderRadius: '4px', overflow: 'hidden' },
287  barFill: { height: '100%', background: 'linear-gradient(90deg, #00d4ff, #00ff88)' },
288  timeline: { background: 'rgba(0,212,255,0.05)', borderRadius: '8px', padding: '12px', border: '1px solid rgba(0,212,255,0.15)', alignSelf: 'start' },
289  timelineItem: { display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '8px', fontSize: '11px' },
290  time: { color: '#8892b0', width: '48px', textAlign: 'right', flexShrink: 0 },
291};

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is the main difference between renderToPipeableStream and renderToReadableStream?

  2. 2. Which feature is the MAIN advantage of Edge Runtime compared to traditional Node.js?

Hands-on tasks in the game

  • Vertical ordering

    Order the stages of Streaming SSR from first to last:

  • Horizontal ordering

    Arrange wrapping a component in ErrorBoundary + Suspense from left to right:

  • Click in order

    Arrange the useSuspenseQuery syntax with TanStack React Query:

Useful articles