Nella revisione di applicazioni real-time incontro quasi sempre la stessa architettura. Il canale WebSocket è aperto, funzionante, bidirezionale — e viene usato per mandare un messaggio che dice, in sostanza, «è cambiato qualcosa, vieni a vedere».
// Il server ha aperto un canale bidirezionale... e lo usa per dire "guarda che// è successo qualcosa". Il dato vero arriva da un'altra parte.socket.addEventListener('message', async (ev) => { const avviso = JSON.parse(ev.data); // { nuoviEventi: 3, modificati: 1 } if (!avviso.nuoviEventi && !avviso.modificati) return; const r = await fetch('/api/eventi', { credentials: 'include' }); const eventi = await r.json(); render(eventi);});// lato server: la notifica non porta niente con séfunction onEventiCambiati(canale, delta) { const avviso = JSON.stringify({ nuoviEventi: delta.nuovi.length, modificati: delta.modificati.length }); for (const ws of iscritti(canale)) ws.send(avviso);}
Il canale c'è, è già autenticato, è già aperto, e trasporta quaranta byte di metadati per poi delegare il lavoro vero a una richiesta HTTP separata. Vale la pena capire con precisione che cosa costa — perché la risposta non è quella che si legge di solito, e in un caso su tre il pattern è la scelta corretta.
Quanto costa davvero#
Sul filo#
Un frame WebSocket ha un'intestazione di 2 byte per payload fino a 125 byte; diventano 4 fino a 64 KiB e 10 oltre. I frame che vanno dal client al server portano in più una masking key di 4 byte, obbligatoria per specifica. Sono i numeri della sezione 5.2 dell'RFC 6455, non stime.
Una richiesta HTTP/1.1 di un browser reale non ha niente di paragonabile: riga di richiesta, Host, User-Agent, Accept, Accept-Language, Accept-Encoding, Referer, Sec-Fetch-* e soprattutto Cookie. Con un cookie di sessione si sta comodamente fra i 500 e i 900 byte, per ogni richiesta, in salita — cioè sul lato lento della connessione.
| Aggiornamento da ~600 byte utili | notifica + fetch | sync diretta |
|---|---|---|
| Frame WebSocket | 2 B + 40 B avviso | 2 B + 600 B dato |
| Header richiesta HTTP/1.1 | ~600 B | — |
| Header risposta HTTP/1.1 | ~250 B | — |
| Corpo risposta | 600 B | — |
| Totale HTTP/1.1 | ~1 490 B | ~600 B |
| Totale HTTP/2 (HPACK a regime) | ~710 B | ~600 B |
Nel tempo#
Qui non c'è compressione che tenga. Con la sincronizzazione diretta il dato è sullo schermo quando arriva il frame. Con notifica + fetch bisogna aggiungere, dopo la notifica, un round-trip completo più il tempo di risposta del server:
sync diretta │████│ ~8 msnotifica + fetch │████│─── RTT 45 ms ───│██ query 12 ms ██│ ~65 ms frame andata/ritorno lavoro serverSu fibra con 10 ms di RTT la differenza è un fastidio teorico. Su 4G, dove 40-70 ms di RTT sono la norma, ogni aggiornamento arriva con un ritardo percepibile — e in un'interfaccia che si aggiorna di continuo il ritardo non si somma, si nota: è la differenza fra un valore che cambia e un valore che scatta.
Sul server#
È il costo che scala peggio, e l'unico che può far cadere il servizio. Con la sincronizzazione diretta una modifica produce una serializzazione JSON, riusata per tutti gli iscritti al canale: per client resta solo una send() su un buffer già pronto.
Con notifica + fetch, la stessa modifica notificata a 1 000 client produce 1 000 richieste HTTP nell'arco di pochi millisecondi: 1 000 parsing, 1 000 verifiche di sessione, 1 000 query, 1 000 serializzazioni. È un thundering herd che vi siete costruiti in casa, e il primo a cedere non è la CPU: è il pool di connessioni al database.
Il bug che nessuno si aspetta: l'ordine#
Questa parte del pattern è raramente discussa e produce dati corrotti in modo silenzioso. Due modifiche ravvicinate generano due notifiche, quindi due fetch in volo contemporaneamente. Le risposte HTTP non hanno alcun obbligo di tornare nell'ordine in cui sono partite: bilanciatori diversi, repliche di lettura diverse, code diverse.
// Due notifiche a 5 ms di distanza, due fetch in volo insieme.// Non c'è niente che garantisca che le risposte tornino nell'ordine giusto.socket.onmessage = async () => { const eventi = await fetch('/api/eventi').then(r => r.json()); render(eventi); // ← l'ultima risposta ARRIVATA vince,}; // non l'ultima EMESSAIl risultato è uno stato più vecchio che sovrascrive uno più recente, e resta lì finché non arriva un altro aggiornamento. L'utente vede un dato sbagliato; nei log non c'è niente. La toppa è un contatore di generazione:
// La toppa: un contatore che scarta le risposte superate.let ultimaRichiesta = 0;socket.onmessage = async () => { const mia = ++ultimaRichiesta; const eventi = await fetch('/api/eventi').then(r => r.json()); if (mia !== ultimaRichiesta) return; // nel frattempo ne è partita un'altra render(eventi);};Funziona. Ma vale la pena notare che cosa è appena successo: abbiamo scritto codice di sincronizzazione per riparare un problema che abbiamo introdotto noi uscendo da un canale che l'ordine lo garantiva già. Il WebSocket viaggia su TCP: dentro una connessione i messaggi arrivano nell'ordine in cui sono partiti, sempre.
La sincronizzazione diretta#
La forma corretta è banale da scrivere. Il messaggio porta il dato:
// Il messaggio porta il dato. Nessuna seconda andata e ritorno.function onEventiCambiati(canale, delta) { const payload = JSON.stringify({ t: 'sync', seq: prossimoSeq(canale), // ordine esplicito, vedi sotto put: delta.nuovi.concat(delta.modificati), del: delta.eliminati.map(e => e.id) }); // Una sola serializzazione riusata per tutti gli iscritti: il costo per // client scende a una send() su un buffer già pronto. for (const ws of iscritti(canale)) ws.send(payload);}socket.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.t !== 'sync') return; // Il WebSocket viaggia su TCP: dentro una connessione l'ordine dei messaggi // è garantito. Il seq serve a riconoscere i buchi dopo una riconnessione, // non a riordinare. if (attesoSeq !== null && m.seq !== attesoSeq) return richiediSnapshot(); attesoSeq = m.seq + 1; for (const ev of m.put) stato.set(ev.id, ev); for (const id of m.del) stato.delete(id); render(stato);});Un solo messaggio, zero richieste, aggiornamento immediato e ordine garantito dal trasporto. Se l'articolo finisse qui sarebbe uno dei tanti che consigliano la cosa giusta senza dire che cosa comporta. Comporta tre cose.
1. Un protocollo, non solo un formato#
Finché la connessione regge, TCP basta. Il problema comincia alla riconnessione: il client è stato via quattro secondi, e in quei quattro secondi lo stato è cambiato. Serve un numero di sequenza per canale e un modo per dichiarare dove si era rimasti.
// Alla riapertura del socket il client dichiara dove era rimasto.socket.addEventListener('open', () => { socket.send(JSON.stringify({ t: 'resume', canale: 'eventi', da: attesoSeq }));});// Il server decide: rigiocare il buco, oppure ripartire da capo.// Un backlog illimitato è una perdita di memoria travestita da funzionalità:// oltre una certa distanza lo snapshot costa meno del recupero.const FINESTRA = 500; // quanti messaggi tengo per canalefunction onResume(ws, { canale, da }) { const log = backlog(canale); // ring buffer di FINESTRA elementi const primo = log.length ? log[0].seq : prossimoSeq(canale); if (da == null || da < primo) { return ws.send(JSON.stringify({ t: 'snapshot', seq: prossimoSeq(canale) - 1, stato: statoCompleto(canale) })); } for (const m of log) if (m.seq >= da) ws.send(m.payload);}2. La backpressure, che il fetch vi regalava#
Questo è il punto in cui la sincronizzazione diretta si rompe davvero, e vale la pena essere espliciti: con notifica + fetch la backpressure era implicita. Un client lento semplicemente non richiedeva i dati; il server non se ne accorgeva nemmeno, perché non stava spingendo niente.
Spingendo, invece, send() non aspetta nessuno. Se il socket non smaltisce — rete mobile, scheda in background, dispositivo sotto carico — la coda cresce nella memoria del processo server. Con qualche migliaio di connessioni e un aggiornamento al secondo, la crescita non è teorica.
// Il punto in cui il push diretto si rompe: un client lento.// send() non aspetta nessuno. Se il socket non smaltisce, la coda cresce// nella RAM del server finché il processo non viene ucciso dall'OOM killer.const SOGLIA = 1 << 20; // 1 MiB di arretrato per clientfor (const ws of iscritti(canale)) { if (ws.bufferedAmount > SOGLIA) { // Questo client non sta al passo: smetto di spingergli delta e lo // degrado al modello a notifica. Si riallineerà con uno snapshot. ws.send(JSON.stringify({ t: 'stale' })); continue; } ws.send(payload);}La proprietà si chiama bufferedAmount ed esiste sia sul WebSocket del browser sia sui socket lato server. Leggerla prima di ogni send() costa nulla e trasforma un crash per esaurimento memoria in un client che si riallinea con uno snapshot.
3. L'autorizzazione al momento della pubblicazione#
Il fetch faceva un altro regalo che si nota solo quando sparisce: ogni GET ripassava dal middleware di autorizzazione, che filtrava i campi in base all'utente. Spingendo una serializzazione unica a tutti gli iscritti di un canale, quel filtro non c'è più.
// Con il fetch l'autorizzazione era implicita: ogni GET ripassava dal// middleware che filtrava per utente. Spingendo il dato, quel passaggio// non avviene più — e va rifatto a mano, al momento della pubblicazione.// ✗ Una sola serializzazione per tutti: il campo riservato arriva a chiunque.const payload = JSON.stringify({ t: 'sync', put: eventi });for (const ws of iscritti(canale)) ws.send(payload);// ✓ Una vista per ruolo, serializzata una volta per ruolo e non per utente.const perRuolo = new Map();for (const ws of iscritti(canale)) { let p = perRuolo.get(ws.ruolo); if (!p) perRuolo.set(ws.ruolo, p = JSON.stringify({ t: 'sync', put: eventi.map(e => proiettaPer(ws.ruolo, e)) })); ws.send(p);}Quando "notifica + fetch" è la scelta giusta#
Non è un antipattern. È un pattern con un dominio di validità preciso, e ci sono casi in cui è la sola scelta difendibile:
- Payload grandi. Sopra qualche decina di KiB si vuole HTTP: compressione negoziata, richieste parziali, streaming, la possibilità di interrompere. Un frame WebSocket da 4 MiB è un frame da 4 MiB che occupa il canale.
- Risorse cacheabili e condivise. Se l'aggiornamento è uguale per tutti e la risorsa ha un'
ETag, la CDN fa il fan-out al posto vostro e il server risponde304. Con il push il server parla con ogni client, sempre. - Client che potrebbero non volerlo. Scheda in background, componente non montato, vista non aperta: notificare costa 40 byte, spingere costa il payload intero a chi non lo guarderà.
- Autorizzazione complessa per riga. Se decidere che cosa un utente può vedere richiede una query, rifarlo per ogni iscritto a ogni pubblicazione è peggio che lasciarlo fare al percorso HTTP.
- Client lenti e senza controllo sul parco dispositivi. Vedi sopra: la backpressure implicita è una proprietà, non un caso.
La forma ibrida#
In produzione la scelta non è binaria. Il messaggio porta il dato finché è piccolo, e sopra una soglia degrada da solo a un riferimento — con il numero di sequenza che permette al client di chiedere esattamente il pezzo che gli manca:
// La forma matura: il delta viaggia inline finché è piccolo, e sopra una// soglia il messaggio degrada da solo a un riferimento da recuperare.const SOGLIA_INLINE = 32 * 1024; // 32 KiBconst delta = JSON.stringify({ t: 'sync', seq, put, del });const messaggio = delta.length <= SOGLIA_INLINE ? delta : JSON.stringify({ t: 'sync-ref', seq, url: `/api/eventi?since=${seq - 1}` });for (const ws of iscritti(canale)) ws.send(messaggio);La soglia è un parametro da tarare, non un dogma: 32 KiB è un punto di partenza ragionevole per dati strutturati. Il vantaggio è che il caso comune — l'aggiornamento piccolo e frequente, che è il novanta per cento del traffico real-time — smette di pagare il round-trip, e il caso raro continua a usare lo strumento adatto.
Misurarlo sul proprio traffico#
Le stime di questo articolo servono a inquadrare l'ordine di grandezza. I numeri che contano sono i vostri, e si ottengono in cinque minuti. Il rapporto fra byte HTTP e byte WebSocket, direttamente in console:
// Quanto pesa davvero un aggiornamento, sui due percorsi.// Da incollare in console con la scheda aperta sull'app.let ws = 0, http = 0;const _send = WebSocket.prototype.send;WebSocket.prototype.send = function (d) { ws += d.length || d.byteLength || 0; return _send.call(this, d); };new PerformanceObserver((l) => { for (const e of l.getEntries()) http += e.transferSize || 0;}).observe({ type: 'resource', buffered: true });setInterval(() => console.log({ ws, http, rapporto: (http / ws).toFixed(1) }), 5000);La latenza percepita end-to-end, che è l'unico numero che l'utente sente davvero:
// Latenza percepita: dal momento in cui il dato è pronto sul server a quando// è sullo schermo. È l'unico numero che conta, e non lo vedi nei log HTTP.socket.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (!m.emessoIl) return; // Richiede orologi allineati (NTP): fra server e client il confronto è // indicativo. Fra due strategie misurate sulla stessa macchina, è valido. performance.measure('sync', { start: m.emessoIl, end: performance.timeOrigin + performance.now() });});E il traffico grezzo del canale, senza strumentare niente: nel pannello Rete di Chrome si filtra per WS, si seleziona la connessione e si apre la scheda Messages. Ogni frame è lì, con dimensione e direzione.
# quante richieste genera un singolo aggiornamento, lato servertail -f /var/log/app/access.log | grep --line-buffered '/api/eventi' | ts '%H:%M:%.S'14:22:07.104 GET /api/eventi 200 61214:22:07.109 GET /api/eventi 200 61214:22:07.109 GET /api/eventi 200 61214:22:07.113 GET /api/eventi 200 612# quattro client, una sola modifica: il pattern è visibile a occhio nudoRiepilogo#
| Aspetto | notifica + fetch | sync diretta |
|---|---|---|
| Banda (HTTP/1.1) | circa 2,5× | riferimento |
| Banda (HTTP/2, HPACK a regime) | circa 1,2× | riferimento |
| Latenza per aggiornamento | +1 RTT + query | nessuna aggiunta |
| Richieste server per modifica | una per client | una serializzazione |
| Ordine degli aggiornamenti | da gestire a mano | garantito da TCP |
| Backpressure | implicita, gratis | da implementare (bufferedAmount) |
| Autorizzazione | ereditata dal middleware | da rifare in pubblicazione |
| Recupero dopo disconnessione | gratis (rilegge tutto) | protocollo seq + snapshot |
| Cache e CDN | utilizzabili | non applicabili |
La conclusione non è «non usate mai il fetch». È che il canale bidirezionale, se lo avete aperto, è già pagato: la connessione, l'handshake, l'autenticazione e il keep-alive sono costi che state sostenendo comunque. Usarlo per suonare un campanello e poi aprire un secondo percorso per il dato è una scelta — legittima in alcuni casi, ma che va fatta sapendo che cosa si compra e che cosa si paga.