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:
| Server | What it did | For whom |
|---|---|---|
| rweb | React app rendered in the browser | People, signed in or not |
| rweb SSR | The same app rendered on the server, with its own URL cleanup rules | Search engine crawlers |
| Jetfuel SSR | Separate server-rendered site: events, Spaces, Communities, Trends, search, news | Indexed 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:
// 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:
// 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.
Watch out for the User-Agent
In the example the crawler is recognized by a header, just to keep things simple. X decides based on the IP address verification done at the edge, not on the User-Agent: otherwise tools like Lighthouse would also get the complete document, and anyone could request the more expensive path by changing a header.
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: bytes of the uncompressed HTML
curl -s https://example.com/ | wc -cHydration 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:
<!-- 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.
If you use bootstrapModules
In our tests with React 19.2, the script passed in bootstrapModules to renderToPipeableStream is already written with async. The problem mostly affects those who add the entry script by hand to the HTML template, as often happens in custom SSR setups with Vite.
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:
// 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.
Measure first, then group
A startup chunk that is too large has its own costs: every change invalidates the cache of the whole block. That is why X keeps dependencies, its own code and the code that changes on every deploy apart. The Rolldown documentation also warns that manual splitting can change the order of module side effects.
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
- X Engineering Blog, "Rebuilding the global town square from scratch": the original article by Yagiz Nizipli, with architecture and numbers
- React, renderToPipeableStream: the API for streaming SSR on Node.js
- HTML Standard, the script element: how module scripts run with and without async
- Google Search Central, Inside Googlebot: the 2 MB limit
- Rolldown, codeSplitting option: groups and tags for manual splitting
