Evitare il callback hell con async/await

In molte applicazioni moderne, le richieste HTTP, le letture di database o le operazioni di I/O vengono gestite tramite funzioni di callback, una pratica che può rapidamente trasformare il codice in una sequenza di rientri difficili da leggere.
Questo fenomeno non è solo una questione estetica: un codice poco leggibile aumenta il rischio di bug, rende più ardua la manutenzione e rallenta il ritmo di sviluppo, soprattutto in progetti di grandi dimensioni.
Nel contesto delle Single‑Page Application o dei micro‑servizi, dove le interazioni con API esterne sono all’ordine del giorno, l’accumularsi di callback annidati può impedire di comprendere rapidamente il flusso di esecuzione.
Il risultato è un codice indentato con più livelli di rientro, in cui ogni funzione dipende dal risultato della precedente, creando una catena difficile da tracciare.
Per chi è abituato a pensare in termini sincroni, questo modello può apparire come un labirinto senza uscita.
Fortunatamente, a partire da ES2017 il linguaggio ha introdotto async/await, una sintassi che permette di scrivere codice asincrono con la leggibilità del codice sincrono.
Nel prosieguo dell’articolo vedremo come passare dal tradizionale schema a callback al nuovo approccio, analizzando vantaggi, limitazioni e le best practice più diffuse.
Cos'è il callback hell
Il termine callback hell descrive una situazione in cui le funzioni di callback sono annidate in più livelli, creando una struttura a forma di piramide rovesciata.
Questo schema è tipico quando ogni operazione asincrona dipende dal risultato della precedente, ad esempio nella sequenza di richieste HTTP mostrata qui sotto:
function getData(url, callback) {
// effettuare una richiesta HTTP a un URL e passare i dati ottenuti a una callback
// ...
}
getData('http://example.com/data1', function (data1) {
getData('http://example.com/data2/' + data1, function (data2) {
getData('http://example.com/data3?data=' + data2, function (data3) {
console.log(data3);
});
});
});In questo esempio, tre richieste HTTP sono annidate, con ogni chiamata che dipende dai dati restituiti dalla precedente.
Il flusso di controllo diventa difficile da seguire, poiché è necessario indentare ogni livello di callback, aumentando la complessità visiva e logica del codice.
Oltre alla difficoltà di lettura, il callback hell può generare problemi di gestione degli errori: è necessario verificare esplicitamente ogni possibile errore all’interno di ogni callback, altrimenti le eccezioni potrebbero non essere catturate correttamente, compromettere l’affidabilità dell’intera applicazione.
Come async/await semplifica il codice asincrono
Introducendo async/await, JavaScript fornisce una sintassi che permette di trasformare le catene di callback in un flusso lineare, più simile al codice sincrono tradizionale.
Una funzione dichiarata con la keyword async restituisce sempre una promise; all’interno di essa possiamo utilizzare la keyword await per sospendere l’esecuzione finché la promise non si risolve, senza bloccare il thread principale.
Ecco una versione riscritta dell’esempio precedente usando async/await:
async function getData(url) {
const response = await fetch(url);
return await response.json();
}
async function main() {
const data1 = await getData('http://example.com/data1');
const data2 = await getData('http://example.com/data2/' + data1);
const data3 = await getData('http://example.com/data3?data=' + data2);
console.log(data3);
}
main();Il codice risulta molto più leggibile: ogni operazione è espressa su una singola riga, senza annidamenti, e il flusso di esecuzione segue l’ordine logico delle operazioni.
Inoltre, la gestione degli errori diventa più semplice, poiché possiamo avvolgere il corpo della funzione main in un blocco try…catch unico, catturando tutte le eccezioni generate dalle chiamate await.
Un altro vantaggio è la coerenza con le API basate su Promise già esistenti (come fetch), permettendo di combinare senza sforzo le vecchie e le nuove modalità di programmazione asincrona.
Gestire più promesse con Promise.all
Sebbene l’uso di più await sequenziali sia chiaro e leggibile, in alcuni scenari è più efficiente avviare più operazioni in parallelo e attendere che tutte si completino contemporaneamente.
Il metodo Promise.all accetta un array di promesse e restituisce una nuova promise che si risolve quando tutte le promesse dell’array hanno completato con successo, oppure si rigetta al primo errore incontrato.
Applicando Promise.all all’esempio precedente otteniamo:
async function main() {
try {
const [data1, data2, data3] = await Promise.all([
getData('http://example.com/data1'),
getData('http://example.com/data2/' + data1), // nota: data1 è disponibile solo dopo la prima risoluzione
getData('http://example.com/data3?data=' + data2) // dipendenza sequenziale rimane
]);
console.log(data3);
} catch (error) {
console.error('Errore nella richiesta:', error);
}
}In questo caso, le due ultime richieste dipendono ancora dai risultati precedenti, ma se le operazioni fossero indipendenti potremmo sfruttare appieno la parallizzazione offerta da Promise.all.
Questo approccio riduce il tempo totale di attesa, poiché le richieste vengono inviate simultaneamente, e semplifica il codice, eliminando la necessità di più blocchi await consecutivi.
Dal punto di vista delle prestazioni, l’utilizzo di Promise.all può portare a un miglioramento significativo quando le chiamate sono indipendenti e il server è in grado di gestire più richieste contemporaneamente.
Tuttavia, è importante valutare le dipendenze tra le operazioni: se una richiesta richiede il risultato di un’altra, è inevitabile mantenere un ordine sequenziale.
Utilizzare i risultati di funzioni async in contesti sincroni
Spesso è necessario integrare il risultato di una funzione async all’interno di codice tradizionale, ad esempio per aggiornare l’interfaccia utente o per passare dati a una libreria che non supporta le promesse.
Il modo più diretto è avvolgere la chiamata in un blocco await all’interno di una funzione dichiarata async, come mostrato di seguito:
async function main() {
const result = await asyncFunction();
console.log('Risultato:', result);
// qui il risultato può essere usato come valore sincrono
}
main();In questo esempio, la variabile result contiene il valore risolto dalla promise, consentendo di utilizzarlo successivamente come se fosse stato ottenuto da una funzione sincrona.
Il codice che segue l’operatore await non viene eseguito prima che la promise sia risolta, garantendo la corretta sequenza delle operazioni.
Quando non è possibile utilizzare await (ad esempio all’interno di un gestore di evento non asincrono), possiamo ricorrere al metodo then della promise:
asyncFunction()
.then(result => {
console.log('Risultato via then:', result);
// operazioni sincrone con il risultato
})
.catch(error => console.error('Errore:', error));Questo approccio consente di gestire il valore risolto in un callback separato, mantenendo comunque la capacità di gestire gli errori tramite catch.
È una soluzione flessibile che si adatta a contesti dove la struttura async/await non è praticabile, garantendo comunque una gestione chiara e leggibile del flusso asincrono.