artCode

Streaming SSR with React: how X rebuilt its web app

What streaming SSR with React and Suspense is, how the new X.com uses it, and two optimizations to apply now: async scripts and fewer startup files.

Tags

  • Javascript
  • Node.js

Published

Reading Time

20 min
The X logo between a red "23,9 s" chip and a green "2,7 s" chip, with three servers merging into one and a tap pouring water: streaming SSR with React in the new X.com

On November 4, 2026 the X team explained how it rebuilt the x.com web app, and the heart of the rewrite is streaming SSR with React: the HTML leaves the server as soon as it is ready and completes as the data arrives. In a lab test run by X on a slow 4G connection, the first post of a profile went from 23.9 seconds to 2.7 seconds.

The original article, written by Yagiz Nizipli (Principal Engineer at X), is packed with details. The headline number matters less than the reason behind it: the slowness was architectural, and the same cause lives in a great many React apps.

In this guide we look at why a fully client-side app is slow on a slow network, how streaming SSR works with React 19 and renderToPipeableStream, how X serves three different audiences from a single server, and two optimizations you can apply too: an async entry script and fewer startup files with Vite 8.

Why a client-side React app is slow on a slow network

A client-side app is slow on a slow network because the content only shows up after a chain of sequential steps: empty HTML, JavaScript download, execution, data request, rendering. Each step waits for the previous one, and on a high-latency network every wait adds up.

X's long-running web app, called rweb, grew out of Twitter Lite, the Progressive Web App launched in 2017. It was a client-side React application with a small Node.js server that only sent an HTML shell. The browser did the rest:

  • HTML: the shell arrives and the splash screen with the logo appears
  • JavaScript: the browser downloads the bundle
  • Execution: the code runs and finds out which data it needs
  • Data: request to the GraphQL API
  • Rendering: finally, the first post

In X's benchmarks on a slow profile, rweb downloaded between 3.0 and 3.6 MB of compressed JavaScript before showing the first post. On profiles and posts the new app downloads none before the first post: the post is already in the HTML.

Three servers for the same page: X's technical debt

Over the years two more servers had grown next to rweb, each one for a different audience:

The three servers of the old x.com and their audiences
ServerWhat it didFor whom
rwebReact app rendered in the browserPeople, signed in or not
rweb SSRThe same app rendered on the server, with its own URL cleanup rulesSearch engine crawlers
Jetfuel SSRSeparate server-rendered site: events, Spaces, Communities, Trends, search, newsIndexed logged-out pages

Each server did its job well. The trouble was having three of them: a fix to a meta description, to structured data or to a 404 page could need to land in three codebases.

The team weighed three options: keep improving rweb, replace only the tooling (Webpack and Babel with Vite, Flow with TypeScript, Express with Fastify) while keeping the same architecture, or build a new app that serves everyone from one server. It chose the third, moving traffic over one piece at a time.

What streaming SSR is in React

Streaming SSR is a way of doing server-side rendering where the server does not wait for the whole page to be ready: it sends the shell right away (layout, title, everything that does not depend on slow data) and then, in the same HTTP response, adds the remaining parts as their data arrives.

In React the line between "ready now" and "coming later" is drawn by Suspense. Anything inside a <Suspense> can suspend: the server sends the fallback and, when the content is ready, appends it to the stream with a small script that moves it into place.

With streaming the content arrives in the first network round trip, instead of waiting for JavaScript to go and ask for it.

The component with Suspense

Here is a minimal profile page with React 19. The Posts component reads a promise with use() and suspends until the data arrives:

JSX
// App.jsx import { Suspense, use } from 'react'; function Posts({ postsPromise }) { // use() suspends the component until the promise resolves const posts = use(postsPromise); return ( <ul> {posts.map((post) => ( <li key={post.id}>{post.text}</li> ))} </ul> ); } export function App({ postsPromise }) { return ( <html lang="en"> <head> <title>Profile</title> </head> <body> <h1>Profile</h1> <Suspense fallback={<p>Loading posts…</p>}> <Posts postsPromise={postsPromise} /> </Suspense> </body> </html> ); }

The title and the h1 belong to the shell; the list of posts is the part that arrives later.

How renderToPipeableStream works

renderToPipeableStream is the react-dom/server API for streaming on Node.js. It takes the React tree and an options object, and returns a pipe function to connect to the HTTP response. The two key callbacks are onShellReady, which fires when the shell is ready, and onAllReady, which fires when everything is ready, Suspense boundaries included.

The React documentation suggests exactly this split: onShellReady to stream to people, onAllReady for crawlers and static generation. It is the same choice X made. Here is a complete server:

JSX
// server.jsx import { createServer } from 'node:http'; import { renderToPipeableStream } from 'react-dom/server'; import { App } from './App.jsx'; // Simulates a slow API: posts arrive after 1.5 seconds function loadPosts() { return new Promise((resolve) => { setTimeout(() => { resolve([ { id: 1, text: 'First post' }, { id: 2, text: 'Second post' }, ]); }, 1500); }); } // Example only: in production the edge does the verification function isVerifiedCrawler(req) { return req.headers['x-verified-crawler'] === '1'; } createServer((req, res) => { const crawler = isVerifiedCrawler(req); const send = () => { res.setHeader('content-type', 'text/html; charset=utf-8'); pipe(res); }; const { pipe } = renderToPipeableStream( <App postsPromise={loadPosts()} />, { // Crawlers never hydrate: no script for them bootstrapModules: crawler ? [] : ['/entry.js'], // People: the shell leaves as soon as it is ready onShellReady() { if (!crawler) send(); }, // Crawlers: a single document, once everything is ready onAllReady() { if (crawler) send(); }, onShellError() { res.statusCode = 500; res.end('<h1>Something went wrong</h1>'); }, } ); }).listen(3000);

We ran this server with React 19.2. A person gets the first chunk after a few milliseconds, with the h1 and the "Loading posts…" fallback; 1.5 seconds later, in the same response, the list arrives. A crawler instead gets a single document after 1.5 seconds, already complete and with no script.

Server data reused by the client

One detail makes a real difference: every GraphQL response the server received travels in the page only once, as JSON text. The browser starts with an empty Relay store and replays those responses into it, so a component asking for data the server already fetched does not repeat the request. Responses that nothing has read yet stay available for 30 seconds after the stream ends.

One server, three audiences: people, search engines, link previews

The same URL, a profile for example, has three audiences with opposite needs. The new app recognizes them and answers in three ways:

  • People: streamed HTML, the shell first and then each part as its data arrives
  • Verified search engines: a complete document, structured data included, with no client bundle and no hydration
  • Link-preview bots (Slack, iMessage, Discord, WhatsApp…): only title, description and image, without the page body
  • Unverified crawlers: exactly what a logged-out visitor gets

Googlebot's 2 MB limit

For crawlers, size matters. According to Google Search Central, Googlebot processes the first 2 MB of an HTML resource and ignores the rest. The HTML of a long X thread ran to 2.2–3.5 MB: Google lost the replies past the cut, and the structured data pointed at posts it never saw.

X fixed it with three changes: the action icons of every post became a single SVG sprite at the top of the page; the structured data of photos no longer repeats text and URLs; and crawler documents dropped everything that only matters once JavaScript runs (timestamp scripts, router state, skeleton posts, sign-in buttons). One command is enough to check the weight of a page of yours:

Terminal
# terminal: bytes of the uncompressed HTML curl -s https://example.com/ | wc -c

Hydration with streaming: async instead of defer

Hydration is the moment React "attaches" to the HTML that came from the server: it wires up the events and makes the page interactive. Streaming brings a subtle problem here, and it is the one X ran into.

A type="module" script is deferred by default: according to the HTML standard it runs only when the browser has finished parsing the whole document. But a streamed document finishes only when the last Suspense boundary arrives. The result: hydration waited for the slowest part of the page.

The fix is one word:

HTML
<!-- before: the module waits for the end of the document --> <script type="module" src="/entry.js"></script> <!-- after: the module runs as soon as it is downloaded --> <script type="module" async src="/entry.js"></script>

With async the module runs as soon as it is available, even before parsing ends. React hydrates the shell right away, and each Suspense boundary hydrates when its HTML lands. In X's test, with a 5-second server-side delay on the conversation query, React attached to the page at 589 ms, while the document finished parsing at 6,017 ms.

Too many startup files: code splitting in Vite and Rolldown

A page cannot hydrate until every file in the static import graph of its entry has arrived. With the default chunking of Rolldown, Vite's bundler, X's entry for signed-in users had 754 of them, almost all holding a single module; the logged-out entry had 582.

Lots of small files do not cost bytes, they cost network round trips. Chrome runs about 100 requests at once on one HTTP/2 connection, and X's CDN allows 100 concurrent streams: 754 files took about eight round trips, even when requested all together.

X wrote a build plugin that packs the modules loaded at startup into three chunks: dependencies, its own code, and the code that names lazily loaded chunks. Startup files dropped from 754 to 182 for signed-in users and from 582 to 10 for everyone else, and every page still loads exactly the same modules as before.

Grouping startup modules with Vite 8

X's plugin is not public, but with Vite 8 you can get a similar effect with Rolldown's codeSplitting option and the $initial tag, which selects the modules loaded at startup:

JavaScript
// vite.config.js import { defineConfig } from 'vite'; export default defineConfig({ build: { rolldownOptions: { output: { codeSplitting: { groups: [ { // the modules needed at startup name: 'startup', tags: ['$initial'], priority: 10, }, ], }, }, }, }, });

We tried it with Vite 8.3 on a small app with 40 startup modules, deliberately split into 40 files to simulate the fragmentation: with the group, the startup modules end up in a single chunk, while the chunk loaded with import() stays separate.

How to read the numbers: lab and field

Almost every number in this article is a lab measurement by X: one laptop, headless Chromium with an empty cache, a simulated network (150 ms of latency, 1.6 Mbps down). X says so first: real devices and networks vary.

The chosen metric is time to first post, not first contentful paint: rweb's first paint came after 1 second, but it was the splash screen. Among the other optimizations, X starts JavaScript only after the stylesheet has loaded: on a slow profile, first contentful paint for a logged-out post went from 7,216 to 1,804 ms.

There are less shiny sides too. The Time To First Byte of the new app is higher (430–560 ms against under 300 ms for rweb), because the server fetches the data before it answers. And the signed-in Home, which the new app still renders only in the browser, improves by 1.7 times on a slow network, against 8 to 13 times for profiles and posts. Next to the lab tests, the app collects Core Web Vitals from real visits.

Frequently asked questions about streaming SSR in React

Do I need streaming SSR if I use Next.js?

Next.js with the App Router already streams with React and Suspense: loading.js files and <Suspense> boundaries work the same way. Understanding renderToPipeableStream helps you know what happens underneath and configure it well, for crawlers for example.

What is the difference between onShellReady and onAllReady?

onShellReady fires when the part of the page outside Suspense boundaries is ready: calling pipe there starts the stream. onAllReady fires when everything is ready: the React documentation recommends it for crawlers and static generation, because it produces a complete document.

Is streaming SSR bad for SEO?

No, as long as crawlers get a complete document. Google can process streamed HTML, but serving the whole page to verified crawlers, as X does with onAllReady, avoids surprises with structured data and with the 2 MB limit.

Should I always use async on the entry script?

With streaming it pays off: a deferred module waits for the end of the document, which means the last Suspense boundary. Without streaming the difference is small, because the document arrives all at once. Check the generated HTML: if you use bootstrapModules, React already adds async.

Conclusion

X's rewrite shows that streaming SSR with React is not a fad: it is the way to send content in the first network round trip, to those who read it and to those who index it, from a single server.

  • Content in the HTML: if users must wait for JavaScript to see the data, a slow network punishes them
  • Suspense as the boundary: the shell leaves right away, slow parts arrive in the same stream
  • Crawlers apart: a complete document with onAllReady, no script, under 2 MB
  • Async script: hydration must not wait for the last Suspense boundary
  • Few startup files: group the initial modules, measure before and after

To go deeper into the split between server and client components, read when to use React Server Components; for the data side, our introduction to GraphQL; for the runtime, the introductory guide to Node.js.

How many JavaScript files does your app download before it becomes interactive? Share your experience in the comments!

Sources

Streaming SSR in React: inside the new X.com architecture | artCode