artCode

Streaming SSR con React: come X ha riscritto la sua web app

Cos'è lo streaming SSR con React e Suspense, come lo usa la nuova X.com e due ottimizzazioni da applicare subito: script async e meno file all'avvio.

Tags

  • Javascript
  • Node.js

Published

Reading Time

20 min
Logo di X tra una chip rossa "23,9 s" e una chip verde "2,7 s", con tre server che confluiscono in uno e un rubinetto che versa acqua: lo streaming SSR React della nuova X.com

Il 4 novembre 2026 il team di X ha raccontato come ha riscritto la web app di x.com, e il cuore della riscrittura è lo streaming SSR React: l'HTML parte dal server appena è pronto e si completa man mano che arrivano i dati. In un test di laboratorio di X su un 4G lento, il primo post di un profilo è passato da 23,9 secondi a 2,7 secondi.

L'articolo originale, firmato da Yagiz Nizipli (Principal Engineer di X), è una miniera di dettagli. Il numero che fa notizia conta meno del perché: la causa della lentezza era architetturale, e la stessa causa si trova in moltissime app React.

In questa guida vediamo perché un'app tutta client-side è lenta su rete lenta, come funziona lo streaming SSR con React 19 e renderToPipeableStream, come X serve tre pubblici diversi con un solo server e due ottimizzazioni che puoi applicare anche tu: lo script di avvio async e meno file all'avvio con Vite 8.

Perché un'app React client-side è lenta su rete lenta

Un'app client-side è lenta su rete lenta perché il contenuto arriva solo dopo una catena di passaggi in sequenza: HTML vuoto, download del JavaScript, esecuzione, richiesta dei dati, rendering. Ogni passaggio aspetta il precedente, e su una rete con molta latenza ogni attesa si somma.

La vecchia web app di X, chiamata rweb, nasceva da Twitter Lite, la Progressive Web App del 2017. Era un'applicazione React client-side con un piccolo server Node.js che mandava solo una shell HTML. Il resto lo faceva il browser:

  • HTML: arriva la shell, si vede lo splash screen con il logo
  • JavaScript: il browser scarica il bundle
  • Esecuzione: il codice parte e scopre quali dati servono
  • Dati: richiesta all'API GraphQL
  • Rendering: finalmente il primo post

Nei benchmark di X su profilo lento, rweb scaricava tra 3,0 e 3,6 MB di JavaScript compresso prima di mostrare il primo post. La nuova app, su profili e post, non ne scarica nessuno prima del primo post: il post è già nell'HTML.

Tre server per la stessa pagina: il debito tecnico di X

Negli anni, accanto a rweb erano nati altri due server, ognuno per un pubblico diverso:

I tre server della vecchia x.com e il loro pubblico
ServerCosa facevaPer chi
rwebApp React resa nel browserPersone, loggate e non
rweb SSRLa stessa app resa sul server, con regole proprie per ripulire gli URLCrawler dei motori di ricerca
Jetfuel SSRSito server-rendered separato: eventi, Spaces, Communities, Trends, ricerca, newsPagine logged-out indicizzate

Ogni server faceva bene il suo lavoro. Il problema era averne tre: una correzione alla meta description, ai dati strutturati o a una pagina 404 poteva dover arrivare in tre codebase.

Il team ha valutato tre strade: migliorare rweb, sostituire solo gli strumenti (Webpack e Babel con Vite, Flow con TypeScript, Express con Fastify) tenendo la stessa architettura, oppure costruire un'app nuova che servisse tutti da un solo server. Ha scelto la terza, spostando il traffico un pezzo alla volta.

Cos'è lo streaming SSR in React

Lo streaming SSR è un modo di fare server side rendering in cui il server non aspetta che tutta la pagina sia pronta: manda subito la shell (layout, titolo, tutto quello che non dipende da dati lenti) e poi, nella stessa risposta HTTP, aggiunge i pezzi man mano che i loro dati arrivano.

In React il confine tra "pronto subito" e "arriva dopo" lo disegna Suspense. Tutto quello che sta dentro un <Suspense> può sospendere: il server manda il fallback e, quando il contenuto è pronto, lo invia in coda allo stream con un piccolo script che lo mette al posto giusto.

Con lo streaming il contenuto arriva nel primo giro di rete, invece di aspettare che il JavaScript lo vada a chiedere.

Il componente con Suspense

Ecco una pagina profilo minima con React 19. Il componente Posts legge una promise con use() e sospende finché i dati non arrivano:

JSX
// App.jsx import { Suspense, use } from 'react'; function Posts({ postsPromise }) { // use() sospende il componente finché la promise non è risolta const posts = use(postsPromise); return ( <ul> {posts.map((post) => ( <li key={post.id}>{post.text}</li> ))} </ul> ); } export function App({ postsPromise }) { return ( <html lang="it"> <head> <title>Profilo</title> </head> <body> <h1>Profilo</h1> <Suspense fallback={<p>Carico i post…</p>}> <Posts postsPromise={postsPromise} /> </Suspense> </body> </html> ); }

Il titolo e l'h1 fanno parte della shell; la lista dei post è il pezzo che arriva dopo.

Come funziona renderToPipeableStream

renderToPipeableStream è l'API di react-dom/server per lo streaming su Node.js. Riceve l'albero React e un oggetto di opzioni, e restituisce un pipe da collegare alla risposta HTTP. I due callback chiave sono onShellReady, che scatta quando la shell è pronta, e onAllReady, che scatta quando è pronto tutto, Suspense compresi.

La documentazione di React suggerisce proprio questa divisione: onShellReady per lo streaming verso le persone, onAllReady per i crawler e la generazione statica. È la stessa scelta di X. Ecco un server completo:

JSX
// server.jsx import { createServer } from 'node:http'; import { renderToPipeableStream } from 'react-dom/server'; import { App } from './App.jsx'; // Simula un'API lenta: i post arrivano dopo 1,5 secondi function loadPosts() { return new Promise((resolve) => { setTimeout(() => { resolve([ { id: 1, text: 'Primo post' }, { id: 2, text: 'Secondo post' }, ]); }, 1500); }); } // Solo per l'esempio: in produzione la verifica la fa l'edge 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()} />, { // I crawler non fanno hydration: niente script per loro bootstrapModules: crawler ? [] : ['/entry.js'], // Persone: la shell parte appena è pronta onShellReady() { if (!crawler) send(); }, // Crawler: un documento solo, quando è tutto pronto onAllReady() { if (crawler) send(); }, onShellError() { res.statusCode = 500; res.end('<h1>Qualcosa è andato storto</h1>'); }, } ); }).listen(3000);

Abbiamo provato questo server con React 19.2. Una persona riceve il primo pezzo dopo pochi millisecondi, con l'h1 e il fallback "Carico i post…"; dopo 1,5 secondi, nella stessa risposta, arriva la lista. Un crawler riceve invece un solo documento dopo 1,5 secondi, già completo e senza script.

Dati del server riusati dal client

Un dettaglio che fa la differenza: ogni risposta GraphQL ricevuta dal server viaggia nella pagina una volta sola, come testo JSON. Il browser parte con uno store Relay vuoto e ci riversa quelle risposte, così un componente che chiede dati già scaricati dal server non rifà la richiesta. Le risposte non ancora lette restano disponibili per 30 secondi dopo la fine dello stream.

Un server, tre pubblici: persone, motori di ricerca, anteprime dei link

La stessa URL, per esempio un profilo, ha tre pubblici con esigenze opposte. La nuova app li riconosce e risponde in tre modi:

  • Persone: HTML in streaming, la shell subito e poi ogni parte quando arrivano i dati
  • Motori di ricerca verificati: documento completo, dati strutturati inclusi, senza bundle client e senza hydration
  • Bot delle anteprime (Slack, iMessage, Discord, WhatsApp…): solo titolo, descrizione e immagine, senza il corpo della pagina
  • Crawler non verificati: esattamente quello che riceve un visitatore non loggato

Il limite di 2 MB di Googlebot

Per i crawler la dimensione conta. Secondo Google Search Central, Googlebot elabora i primi 2 MB di una risorsa HTML e ignora il resto. L'HTML di un thread lungo di X arrivava a 2,2–3,5 MB: Google perdeva le risposte oltre il limite, e i dati strutturati puntavano a post che non vedeva.

X ha risolto con tre interventi: le icone delle azioni di ogni post sono diventate un unico sprite SVG in cima alla pagina; i dati strutturati delle foto non ripetono più testo e URL; dai documenti per i crawler sono spariti gli elementi che servono solo quando gira il JavaScript (script dei timestamp, stato del router, post scheletro, pulsanti di accesso). Per controllare il peso di una tua pagina basta un comando:

Terminal
# terminale: byte dell'HTML non compresso curl -s https://example.com/ | wc -c

Hydration con lo streaming: async invece di defer

L'hydration è il momento in cui React "aggancia" l'HTML arrivato dal server: collega gli eventi e rende la pagina interattiva. Con lo streaming nasce un problema sottile, ed è quello che X ha trovato.

Uno script type="module" è differito per default: secondo lo standard HTML viene eseguito solo quando il browser ha finito di analizzare tutto il documento. Ma un documento in streaming finisce solo quando arriva l'ultimo Suspense. Risultato: l'hydration aspettava la parte più lenta della pagina.

La soluzione è una parola:

HTML
<!-- prima: il modulo aspetta la fine del documento --> <script type="module" src="/entry.js"></script> <!-- dopo: il modulo parte appena è scaricato --> <script type="module" async src="/entry.js"></script>

Con async il modulo viene eseguito appena è disponibile, anche prima della fine del parsing. React idrata subito la shell, e ogni Suspense si idrata quando arriva il suo HTML. Nel test di X, con 5 secondi di ritardo lato server sulla query della conversazione, React si è agganciato alla pagina a 589 ms, mentre il documento ha finito il parsing a 6.017 ms.

Troppi file all'avvio: il code splitting di Vite e Rolldown

Una pagina non può fare hydration finché non sono arrivati tutti i file del grafo di import statici del suo entry. Con il chunking di default di Rolldown, il bundler di Vite, l'entry per gli utenti loggati di X ne aveva 754, quasi tutti con un solo modulo dentro; quello per gli utenti non loggati 582.

Tanti file piccoli non costano in byte, costano in giri di rete. Chrome gestisce circa 100 richieste contemporanee su una connessione HTTP/2, e la CDN di X accetta 100 stream contemporanei: 754 file richiedevano circa otto giri di rete, anche chiesti tutti insieme.

X ha scritto un plugin di build che raggruppa i moduli caricati all'avvio in tre chunk: le dipendenze, il proprio codice e il codice che nomina i chunk caricati in modo lazy. I file di avvio sono scesi da 754 a 182 per gli utenti loggati e da 582 a 10 per gli altri, e ogni pagina carica esattamente gli stessi moduli di prima.

Raggruppare i moduli di avvio con Vite 8

Il plugin di X non è pubblico, ma con Vite 8 puoi ottenere un effetto simile con l'opzione codeSplitting di Rolldown e il tag $initial, che seleziona i moduli caricati all'avvio:

JavaScript
// vite.config.js import { defineConfig } from 'vite'; export default defineConfig({ build: { rolldownOptions: { output: { codeSplitting: { groups: [ { // i moduli che servono all'avvio name: 'startup', tags: ['$initial'], priority: 10, }, ], }, }, }, }, });

L'abbiamo provata con Vite 8.3 su una piccola app con 40 moduli di avvio, separati apposta in 40 file per simulare la frammentazione: con il gruppo, i moduli di avvio finiscono in un solo chunk, mentre il chunk caricato con import() resta separato.

Come leggere i numeri: laboratorio e campo

Quasi tutti i numeri di questo articolo sono misure di laboratorio di X: un laptop, Chromium headless con cache vuota, rete simulata (150 ms di latenza, 1,6 Mbps in download). X lo dice per prima: i dispositivi e le reti reali variano.

La metrica scelta è il tempo al primo post, non il first contentful paint: il primo paint di rweb arrivava dopo 1 secondo, ma era lo splash screen. Tra le altre ottimizzazioni, X fa partire il JavaScript solo dopo il foglio di stile: su profilo lento il first contentful paint di un post non loggato è sceso da 7.216 a 1.804 ms.

Ci sono anche i lati meno brillanti. Il Time To First Byte della nuova app è più alto (430–560 ms contro meno di 300 ms di rweb), perché il server recupera i dati prima di rispondere. E la Home loggata, che la nuova app rende ancora solo nel browser, migliora di 1,7 volte su rete lenta, contro le 8–13 volte di profili e post. Accanto ai test di laboratorio, l'app raccoglie i Core Web Vitals delle visite reali.

Domande frequenti su streaming SSR React

Lo streaming SSR serve anche se uso Next.js?

Next.js con l'App Router usa già lo streaming con React e Suspense: i file loading.js e i confini <Suspense> funzionano allo stesso modo. Capire renderToPipeableStream aiuta a sapere cosa succede sotto e a configurarlo bene, per esempio per i crawler.

Qual è la differenza tra onShellReady e onAllReady?

onShellReady scatta quando è pronta la parte della pagina fuori dai Suspense: chiamando lì pipe parte lo streaming. onAllReady scatta quando è pronto tutto: la documentazione di React lo consiglia per crawler e generazione statica, perché produce un documento completo.

Lo streaming SSR fa male alla SEO?

No, se i crawler ricevono un documento completo. Google sa elaborare HTML in streaming, ma servire ai crawler verificati la pagina intera, come fa X con onAllReady, evita sorprese con i dati strutturati e con il limite di 2 MB.

Devo usare sempre async sullo script di avvio?

Con lo streaming conviene: un modulo differito aspetta la fine del documento, cioè l'ultimo Suspense. Senza streaming la differenza è piccola, perché il documento arriva tutto insieme. Controlla l'HTML generato: se usi bootstrapModules, React mette già async.

Conclusione

La riscrittura di X mostra che lo streaming SSR React non è una moda: è il modo di mandare il contenuto nel primo giro di rete, a chi lo legge e a chi lo indicizza, con un solo server.

  • Contenuto nell'HTML: se l'utente deve aspettare il JavaScript per vedere i dati, la rete lenta lo punisce
  • Suspense come confine: la shell parte subito, i pezzi lenti arrivano nello stesso stream
  • Crawler a parte: documento completo con onAllReady, senza script, sotto i 2 MB
  • Script async: l'hydration non deve aspettare l'ultimo Suspense
  • Pochi file all'avvio: raggruppa i moduli iniziali, misura prima e dopo

Se vuoi ripassare la differenza tra componenti server e client, leggi quando usare i React Server Components; per il lato dati, la nostra introduzione a GraphQL; per il runtime, la guida introduttiva a Node.js.

Quanti file JavaScript scarica la tua app prima di diventare interattiva? Condividi la tua esperienza nei commenti!

Fonti

Streaming SSR React: la nuova architettura di X.com | artCode