Scalare Node.js, la versione noiosa
La maggior parte degli articoli su "come scalare Node.js" è scritta da persone che hanno scalato un singolo servizio a un milione di richieste al giorno e danno per scontato che il risultato si generalizzi. Noi abbiamo gestito diversi sistemi oltre i 10 milioni di richieste al giorno, e le lezioni imparate non sono affatto affascinanti.
Questo è il manuale che avremmo voluto avere quattro anni fa.
Basta discutere sui microservizi
L'errore più costoso che abbiamo visto commettere ai team è dividere in microservizi prima di esserne davvero costretti. Un monolite con confini di modulo puliti scala molto più di quanto la maggior parte degli sviluppatori si aspetti, ed è un ordine di grandezza più economico da gestire.
La nostra regola: restare monolitici finché non si verifica una di queste condizioni.
- Un percorso specifico ha caratteristiche di scalabilità diverse dal resto (ad esempio un endpoint di inferenza ML particolarmente pesante).
- Un percorso specifico ha un ritmo di deployment diverso (ad esempio codice soggetto a compliance che deve essere rilasciato mensilmente mentre il resto viene rilasciato quotidianamente).
- Il team ha superato i 25 sviluppatori e la contesa sui merge è un problema concreto.
Se nessuna di queste condizioni è vera, dividere in servizi aggiunge solo costo operativo — service discovery, tracing distribuito, orchestrazione dei deployment, modalità di guasto di rete — senza offrire nulla che non si possa già ottenere da un monorepo ben organizzato.
Il vero collo di bottiglia è il database
In ogni progetto di scaling Node.js che abbiamo affrontato, il collo di bottiglia si è spostato prima o poi sul database. CPU e memoria dei servizi Node.js sono di solito a posto, perché Node è bravo nella concorrenza I/O. I problemi sono:
- Pool di connessioni senza limiti che mandano in crisi Postgres
- Un singolo indice "caldo" che diventa un collo di bottiglia in scrittura
- Query N+1 negli ORM che sembrano innocue in fase di code review
- Transazioni di lunga durata che mantengono lock durante l'esecuzione della logica di business
- Traffico in lettura sul nodo primario invece che su una read replica
Le prime mosse pratiche:
- PgBouncer o equivalente davanti a Postgres, in modalità transaction pooling, con un limite di connessioni basato sulla CPU reale del database e non su intuizioni lato Node.js.
- Read replica con routing esplicito. Non lasciare che sia l'ORM a decidere. Avere due client nel codice:
db.readedb.write. Obbligare ogni sviluppatore a scegliere. - Misurazione dei tempi delle query in produzione. Pino + un logger per le query lente. L'obiettivo è un alert su Slack quando una query supera i 200ms.
- EXPLAIN ANALYZE in CI per le nuove query. Lo aggiungiamo come controllo nelle PR per ogni modifica che tocca lo schema.
Caching, su tre livelli
Il caching ti salva in quest'ordine: CDN, in-memory, Redis. Se salti un livello, prima o poi te ne pentirai.
CDN. Tutto ciò che può essere messo in cache all'edge dovrebbe esserlo. Questo include anche le risposte API per le letture non autenticate. Niente soluzioni troppo ingegnose — header Cache-Control e una CDN che li rispetta risolvono gratis il 60% del traffico in lettura.
In-memory. Una piccola cache LRU (noi usiamo lru-cache) davanti a Redis intercetta la coda lunga delle richieste ripetute all'interno di un singolo processo. Sembra banale, ma nei nostri profili fa risparmiare dal 30 al 50% del traffico verso Redis.
Redis. Per lo stato condiviso e i valori calcolati che devono sopravvivere tra i processi. Usalo con intenzione — ogni chiave deve avere un proprietario, un TTL e una strategia di invalidazione documentata. Le chiavi Redis non tracciate sono il motivo per cui le cache di produzione finiscono per servire dati vecchi di 6 mesi durante il Black Friday.
Rendi asincrono tutto ciò che può esserlo
I percorsi di richiesta sincroni dovrebbero fare il minimo indispensabile: validare, persistere, rispondere. Tutto il resto — email, invio di webhook, elaborazione delle immagini, chiamate API a servizi terzi, indicizzazione per la ricerca — va messo in coda.
Usiamo BullMQ su Redis per quasi tutto. Due motivi:
- Isolamento dei fallimenti. Un'API di terze parti instabile non può far cadere il tuo percorso di richiesta se la chiamata avviene in un worker.
- Backpressure. Quando il traffico ha un picco, la coda assorbe l'ondata e i worker la smaltiscono a un ritmo sostenibile. Il codice sincrono, sotto un picco, semplicemente collassa.
Esempio concreto: un flusso di checkout che abbiamo ricostruito per un cliente eseguiva 6 chiamate a servizi terzi in maniera sincrona (tasse, frode, fulfillment, email, SMS, analytics). Il checkout mediano richiedeva 1.4 secondi e il p99 saliva a 8 secondi quando uno dei provider rallentava. Dopo aver spostato tutto tranne tasse e frode su worker BullMQ, la mediana è scesa a 220ms e il p99 è rimasto sotto i 600ms.
Osservabilità prima dell'architettura ingegnosa
L'istinto, quando si scala, è progettare sistemi ingegnosi. La mossa giusta è invece strumentare il sistema che già hai finché non riesci a vedere esattamente dove viene speso il tempo.
Adottiamo come standard:
- Tracing con OpenTelemetry, esportato verso Honeycomb o Tempo. Ogni richiesta ha un trace ID. Ogni chiamata esterna è uno span.
- Log strutturati, in formato JSON, con il trace ID. Pino va benissimo.
- Metriche RED per endpoint — Rate, Errors, Duration. Dashboard Grafana. Alert sul p99, non sul p50.
- Controlli sintetici dall'esterno della tua rete, eseguiti ogni minuto sui percorsi critici.
Non riuscirai a prevedere correttamente i tuoi colli di bottiglia. Li misurerai. Il team che rilascia per primo l'osservabilità si aggiudica i successivi sei mesi di lavoro di scaling.
Le trappole che abbiamo visto ripetersi
Una breve lista di cose che abbiamo visto buttare giù la produzione:
- Loggare troppo, mandare in crisi la pipeline dei log, e ritrovarsi senza log.
- Dimenticare di impostare
--max-old-space-sizedi Node. Il default è 1.5GB. Il tuo container ne ha 8GB. Fai due conti. - Scrivere su un database dentro un loop Promise.all senza limitare la concorrenza. Esaurimento del pool di connessioni nel momento peggiore.
- Health check che passano anche quando il servizio è di fatto rotto. Testa l'intera catena di dipendenze, non solo il processo.
- Fidarsi di un SDK di terze parti per l'impostazione dei timeout. Avvolgilo sempre con il tuo.
La verità poco affascinante
A 10 milioni di richieste al giorno, la differenza tra un sistema che funziona e uno che non funziona raramente sta nel genio architetturale. Sta in un team che monitora le proprie dashboard, corregge le query lente nella stessa settimana in cui compaiono, conduce le revisioni post-incidente con onestà e resiste alla tentazione di rifattorizzare quando dovrebbe invece strumentare.
Costruisci questa cultura e Node.js reggerà molto più a lungo di quanto suggeriscano i talk alle conferenze. Questo è esattamente il tipo di lavoro che i nostri team backend e cloud & DevOps svolgono per i prodotti in fase di crescita.



