La sintassi async/await di JavaScript doveva salvarci dal callback hell. Invece, ha introdotto un problema più silenzioso e insidioso: codice che sembra corretto ma si comporta in modo imprevedibile. Vedi await all'interno del corpo della funzione e assumi che tutto si fermi educatamente, riga per riga. Spesso non è così. I cicli corrono avanti. Interi batch crollano a causa di una singola richiesta fallita. I file di entry point generano brutti wrapper async senza motivo. Se ti sei imbattuto in uno di questi problemi, questi tre pattern ti aiuteranno a risolverli.
Smetti di usare await all'interno di forEach
Ecco un errore comune che sembra innocuo sulla pagina:
const urls = ['/api/user', '/api/posts', '/api/comments'];
urls.forEach(async (url) => {
const res = await fetch(url);
const data = await res.json();
console.log(data);
});
console.log('All done!');
Eseguilo, e 'All done!' verrà stampato prima ancora che arrivi una singola risposta. Perché? forEach esegue la callback per ogni elemento immediatamente. Non attende la promise all'interno di ogni iterazione. La parola chiave async trasforma ogni callback in una promise che forEach ignora prontamente. Il tuo ciclo termina in microsecondi; le richieste di rete procedono per conto loro. Se hai bisogno che gli errori vengano gestiti in ordine, o se devi garantire che una richiesta finisca prima che inizi la successiva, questo pattern rompe silenziosamente entrambe le garanzie.
Sostituiscilo con un ciclo for...of:
const urls = ['/api/user', '/api/posts', '/api/comments'];
for (const url of urls) {
const res = await fetch(url);
const data = await res.json();
console.log(data);
}
console.log('All done!');
Ora il ciclo si ferma realmente ad ogni await. La seconda richiesta attende la prima. 'All done!' viene stampato solo dopo che tutto si è stabilizzato.
Usa for...of quando la sequenza è importante: caricare file uno alla volta per rispettare i rate limit, scrivere righe nel database in un ordine specifico o concatenare chiamate API in cui la richiesta successiva ha bisogno dei dati della risposta precedente. Se desideri effettivamente un'esecuzione parallela, non improvvisare con forEach. Usa esplicitamente Promise.all in modo che la tua intenzione sia chiara al prossimo sviluppatore. Ma non mischiare mai await e forEach aspettandoti un comportamento sincrono. Non accadrà.
Usa Promise.allSettled quando lo zero non può essere la risposta
Promise.all è semanticamente onesto. Passagli un array di promise e ti restituirà un array di risultati. Il problema è che nel momento in cui una singola promise viene rifiutata, l'intero processo fallisce immediatamente. Tutte le altre promise in sospeso vengono lasciate finire da sole, ma perdi l'accesso ai loro risultati. In produzione, questo comportamento "tutto o niente" è problematico.
Immagina che la tua applicazione recuperi i widget di una dashboard da quattro servizi indipendenti: analisi del traffico, dati sui ricavi, feedback degli utenti e stato di salute del server. L'API dei ricavi va in timeout. Con Promise.all, l'intera dashboard restituisce un errore. Le tre risposte corrette svaniscono nel nulla. L'utente vede uno spinner e poi una schermata di errore, solo perché un quarto dei dati ha avuto un problema.
Promise.allSettled ti offre un contratto più sensato. Attende che ogni singola promise finisca, indipendentemente dall'esito. Il valore risolto è un array di oggetti che descrivono ogni risultato:
const requests = [
fetch('/api/traffic'),
fetch('/api/revenue'),
fetch('/api/feedback'),
fetch('/api/health')
];
const results = await Promise.allSettled(requests);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
renderWidget(index, result.value);
} else {
renderError(index, result.reason);
}
});
Nessuna risposta viene scartata. Renderizzi ciò che puoi e isoli il fallimento. Questo pattern è fondamentale quando gestisci operazioni non correlate: notifiche di massa, invio di webhook a terze parti o importazione di record da più flussi CSV. Avrai comunque bisogno di un tracciamento centralizzato degli errori, ma la tua applicazione manterrà la stabilità.
Una nota pratica: allSettled restituisce l'intero set, quindi dovrai comunque esaminare i risultati e decidere cosa significhi "successo parziale" per la tua funzionalità. Non trattare l'array restituito come se contenesse solo dati validi. Controlla i campi status prima di inserire qualsiasi cosa nel tuo layer di stato.
Dichiarare top-level await e eliminare il wrapper IIFE
Per anni, se volevi usare await alla radice di un file, dovevi racchiuderlo in una funzione async invocata immediatamente (IIFE):
(async () => {
const config = await loadConfig();
startServer(config);
})();
Questo funziona, ma è solo rumore inutile. Il top-level await, nativo negli ES modules, ti permette di eliminare questo boilerplate cerimoniale:
const config = await loadConfig();
startServer(config);
Usalo nel punto di ingresso della tua applicazione o in moduli di configurazione dedicati dove l'inizializzazione deve completarsi prima che venga eseguito qualsiasi altra cosa. Il caricamento di file di ambiente, l'apertura di un pool di connessioni al database o il recupero di feature flag remote sono casi d'uso ideali. Poiché il top-level await blocca l'esecuzione del grafo dei moduli — gli altri file che importano questo modulo attenderanno la risoluzione della tua promise — otterrai uno stato garantito. Il resto del tuo codice potrà importare db sapendo che la connessione è già attiva.
Ci sono due avvertenze. In primo luogo, il tuo runtime o bundler deve supportare gli ES modules. In Node.js, ciò significa utilizzare le estensioni .mjs o impostare "type": "module" nel tuo package.json. In secondo luogo, poiché l'attesa a livello di modulo ritarda ogni importer, mantieni il lavoro in attesa focalizzato. Fetch sequenziali pesanti all'inizio di un file di utility importato frequentemente rallenteranno l'intero cold start della tua applicazione. Riserva il top-level await per i veri compiti di bootstrap da cui gli altri moduli dipendono realmente.
Cosa cambia effettivamente quando adotti questi pattern
La prevedibilità è il primo vantaggio. Quando leggi un ciclo for...of, sai esattamente quando il blocco sottostante termina. Non ci sono ghost promises che corrono dietro le quinte, né callback foreach che si staccano dai tuoi error handler. Il tuo flusso di controllo corrisponde alla forma del codice sullo schermo.
La resilienza è il passo successivo. Promise.allSettled ti costringe a pensare al fallimento parziale invece di sperare che ogni sistema esterno rimanga perfetto. Il software in produzione non è binario. Alcuni endpoint potrebbero fallire in modo intermittente. Alcune letture di file incontreranno errori di permessi. Progettare per la realtà di fallimenti sparsi mantiene la tua applicazione stabile senza nascondere i dati legittimi sotto il tappeto.
La chiarezza lega tutto insieme. for...of si legge come una progressione fluida e naturale. allSettled dichiara la sua intenzione nel nome. Il top-level await rimuove i crittici wrapper IIFE, così i tuoi file di entry iniziano con la business logic invece che con acrobazie sintattiche. Il prossimo ingegnere che toccherà il file — che sia tu tra sei mesi o un collega con una scadenza imminente — ti ringrazierà.
Un insegnamento concreto
Non trattare async/await come una soluzione globale da spargere sul codice esistente. Analizza i tuoi progetti attuali alla ricerca di questi tre specifici anti-pattern. Cerca await all'interno dei blocchi forEach e sostituiscili con for...of o un intenzionale Promise.all. Esamina ogni Promise.all che comunica con servizi esterni e chiediti se un singolo fallimento debba davvero far naufragare l'intera operazione; in caso contrario, passa a Promise.allSettled e gestisci i risultati misti. Infine, elimina le IIFE async dai tuoi entry point degli ES module e lascia che il top-level await gestisca direttamente la tua sequenza di bootstrap. Si tratta di piccoli cambiamenti meccanici, ma insieme trasformano script asincroni fragili in codice di cui puoi effettivamente fidarti.
