Gestire gli Errori Asincroni in JavaScript con il Try-Catch

JavaScript è uno dei linguaggi di programmazione più diffusi al mondo, utilizzato in milioni di progetti web, sia nel frontend che nel backend, grazie all’emergere di piattaforme come Node.js. La sua capacità di gestire operazioni asincrone lo rende estremamente flessibile, ma allo stesso tempo introduce una complessità notevole nella gestione degli errori. Quando un errore si verifica in un flusso asincrono, può facilmente passare inosservato se non si adottano meccanismi precisi per intercettarlo. Questo problema si acuisce soprattutto quando il codice è distribuito su più livelli logici, come in applicazioni moderne basate su microservizi, eventi e fetch multipli.
Gli errori asincroni sono particolarmente subdoli perché non seguono il classico ciclo sincrono in cui un'istruzione viene eseguita immediatamente e il controllo passa subito a quella successiva. Con il paradigma asincrono, le istruzioni possono essere messe in pausa, delegate a eventi futuri e poi riprese in un secondo momento. Questo significa che l'errore può manifestarsi in un momento in cui il contesto originale è ormai scomparso, rendendo difficile capire da dove sia nato il problema.
Per fortuna, JavaScript offre un meccanismo solido e comprensibile per gestire questi problemi: il costrutto try-catch. Utilizzandolo nel modo corretto, è possibile contenere l'errore, evitare crash improvvisi e perfino implementare una gestione degli errori centralizzata, utile in ambienti più complessi come applicazioni server-side o web app distribuite.
Il blocco try viene utilizzato per racchiudere tutto il codice che si sospetta possa causare un'eccezione. Se all'interno di quel blocco si verifica un errore, JavaScript interrompe l'esecuzione del blocco try e passa direttamente al blocco catch, dove si può decidere cosa fare: loggare l’errore, mostrare un messaggio all’utente, tentare una ripetizione dell’operazione o qualsiasi altra azione correttiva. È possibile aggiungere un terzo blocco, chiamato finally, che viene eseguito sempre, sia che si verifichi un errore sia che tutto fili liscio. Questo è utile per eseguire operazioni di pulizia o rilascio risorse.
Un esempio base di utilizzo asincrono con try-catch, in una funzione async, potrebbe essere il seguente:
async function caricaDati() {
try {
const response = await fetch('https://example.com/api/data');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('Dati ricevuti:', data);
} catch (error) {
console.error('Errore durante il recupero dei dati:', error);
} finally {
console.log('Operazione completata, risorse liberate');
}
}Questo frammento evidenzia alcuni punti fondamentali. La presenza di await obbliga la funzione ad attendere la risoluzione della Promise restituita da fetch. Se qualcosa va storto – ad esempio una rete assente o un JSON malformato – l’errore viene catturato nel blocco catch, evitando che venga propagato oltre in maniera incontrollata.
Ma non tutti gli errori si manifestano direttamente dove pensi. Una delle trappole più comuni è tentare di usare try-catch fuori da un contesto asincrono o fuori dal corpo della callback. Prendi questo codice, per esempio:
try {
window.addEventListener('click', function(event) {
throw new Error('Errore dentro evento click');
});
} catch (error) {
console.error('Questo non verrà mai eseguito:', error);
}Qui il try-catch non intercetta nulla. Il motivo è semplice: l'errore viene generato in un momento successivo, quando la funzione di callback è invocata. A quel punto, il contesto del try non esiste più. L’unico modo corretto per catturare quell’errore è inserire il try-catch dentro la callback stessa, in questo modo:
window.addEventListener('click', function(event) {
try {
throw new Error('Errore dentro evento click');
} catch (error) {
console.error('Errore gestito correttamente:', error);
}
});Questo schema è fondamentale per ogni tipo di evento, inclusi quelli DOM o di librerie esterne. Se lavori in un contesto Node.js, dove gli eventi sono pane quotidiano, questa strategia è imprescindibile. Non puoi pensare di incartare tutto il tuo codice asincrono con un unico try-catch e sperare che tutto venga intercettato: devi mettere il catch esattamente dove il problema può verificarsi.
Altri casi di errori asincroni riguardano operazioni che coinvolgono setTimeout, setInterval, WebSocket, stream, interazioni con il filesystem, e molto altro. Se non hai il controllo su quando verrà eseguito il codice, allora il try-catch deve essere inserito in profondità, esattamente dove l’errore può manifestarsi.
Spesso, nelle applicazioni moderne, gli sviluppatori preferiscono usare middleware, strumenti di logging centralizzati o wrapping automatici delle funzioni per aggiungere try-catch ovunque. Questo può essere utile, ma se non capisci perché un errore sfugge al tuo controllo, finirai con l’imbottire il codice di blocchi try-catch inutili o mal posizionati, ottenendo solo un’illusione di sicurezza.
Non dimenticare che il blocco finally è il tuo alleato quando devi fare cleanup. Se hai aperto una connessione, mostrato un loader, disabilitato un bottone o settato una variabile di stato, finally ti consente di riportare tutto in ordine, a prescindere da come è andata l’operazione.
Detto questo, c’è un errore da evitare nell’esempio più diffuso di tutti, quello del loader:
async function inviaRichiesta(dati) {
mostraLoader();
try {
const res = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dati),
});
const risultato = await res.json();
aggiornaUI(risultato);
} catch (e) {
mostraErrore('Richiesta fallita');
} finally {
nascondiLoader();
}
}Questo codice non rileva gli errori HTTP. fetch non rifiuta la Promise quando il server risponde con 500 Internal Server Error: la Promise si risolve normalmente, res.json() restituisce il corpo di risposta e aggiornaUI viene chiamata come se tutto fosse andato bene. Un errore del server che contiene un JSON valido, come {"esito":"fallito","motivo":"errore interno"}, viene quindi mostrato all’utente come se fosse un risultato valido. La gestione degli errori sembra robusta e non lo è.
La correzione è il controllo di res.ok, che è false per qualsiasi stato fuori dalla famiglia 2xx:
class HttpError extends Error {
constructor(status, statusText) {
super(`HTTP ${status} ${statusText}`);
this.name = 'HttpError';
this.status = status;
}
}
async function inviaRichiesta(dati) {
mostraLoader();
try {
let res;
try {
res = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dati),
});
} catch (causa) {
// Errore di rete: nessuna risposta dal server
throw new Error('Server non raggiungibile', { cause: causa });
}
if (!res.ok) {
// Errore HTTP: la risposta c'è, ma non è quella che volevamo
throw new HttpError(res.status, res.statusText);
}
try {
return await res.json();
} catch (causa) {
// Errore di parsing: stato 200 ma corpo non in JSON
throw new Error('Risposta non in formato JSON valido', { cause: causa });
}
} catch (errore) {
if (errore instanceof HttpError && errore.status === 401) {
mostraErrore('Sessione scaduta, accedi di nuovo');
} else {
mostraErrore('Richiesta fallita');
}
} finally {
nascondiLoader();
}
}I tre catch non sono ridondanti: intercettano tre famiglie di errore diverse che richiedono trattamenti diversi. L’errore di rete e quello di parsing HTTP non si possono distinguere dal solo messaggio, quindi separarli fin dall’origine è l’unico modo per scegliere cosa mostrare all’utente.
Qui il loader viene nascosto sia in caso di successo che in caso di errore. Nascondere il loader è buona pratica, ma da sola non basta a rendere il flusso affidabile: senza il controllo di res.ok l’interfaccia si aggiorna con i dati dell’errore.
Operazioni asincrone in parallelo
Quando più richieste devono partire insieme, Promise.all è la scelta naturale, ma ha un comportamento che va compreso prima di usarlo: rifiuta al primo errore e i risultati delle altre operazioni vengono persi, anche se sono riuscite.
const [utente, ordini] = await Promise.all([
carica('/api/utente'),
carica('/api/ordini'),
]);Se risponde /api/ordini500, l’intera operazione viene rifiutata e non ottieni nulla, nemmeno l’utente che era già arrivato. Va bene quando le due richieste sono realmente dipendenti l’una dall’altra. Quando invece sono indipendenti e tutte servono, Promise.allSettled è lo strumento giusto: non rifiuta mai e restituisce un array di esiti, ciascuno con il proprio stato.
const esiti = await Promise.allSettled([
carica('/api/utente'),
carica('/api/preferiti'),
carica('/api/ordini'),
]);
for (const esito of esiti) {
if (esito.status === 'fulfilled') {
console.log('ok ', esito.value);
} else {
console.log('fallito ', esito.reason.message);
}
}Nell’esempio sopra fallisce e le altre due richieste proseguono: l’utente vede la pagina con i dati disponibili e un avviso per la sezione mancante, invece di una pagina interamente vuota./api/preferiti
Una nota sul nome: Promise.allSettled risolve sempre con successo. L’errore non si propaga, quindi non va gestito con try-catch: va ispezionato nel for con una distinzione su status. Scrivere try { await Promise.allSettled(...) } catch non catturerà mai nulla.
Operazioni asincrone annidate
Quando una chiamata asincronaDipende dal risultato di un’altra, i due await vanno semplicemente in sequenza nella stessa funzione async:
async function caricaDashboard(utenteId) {
try {
const profilo = await carica(`/api/utenti/${utenteId}`);
// Dipende da profilo: non può partire prima
const ordini = await carica(`/api/ordini?utente=${profilo.id}`);
return { profilo, ordini };
} catch (errore) {
console.error('Caricamento dashboard fallito:', errore);
return null;
}
}Il try-catch che racchiude entrambi gli await intercetta sia l’errore del primo sia quello del secondo, perché l’eccezione si propaga lungo la stessa catena di await. Questo vale finché la funzione resta async: è la firma async che trasforma un errore asincrono in un’eccezione sincrona, intercettabile dal catch.
Se invece le chiamate sono indipendenti ma una dipende dall’altra per la lettura del risultato, Promise.all con i valori già risolti rimane l’alternativa più leggibile:
const [profilo, impostazioni] = await Promise.all([
carica(`/api/utenti/${utenteId}`),
carica('/api/impostazioni'),
]);Perché try-catch da solo non basta
Vale la pena fissare cosa try-catch copre e cosa no, perché è l'equivoco più diffuso.
Copre gli errori che si manifestano all’interno del blocco, inclusi quelli delle Promise che hai atteso con await nella stessa funzione async.
Non copre:
- Gli errori HTTP.
fetchrisolve la Promise anche per404e500. Serve il controllo dires.ok. - Le
Promiseche non hai atteso. Setry { unaPromiseCheFallisce(); } catch {}non contieneawait, ilcatchnon intercetta nulla: l’eccezione arriva come una Promise rigettata e diventa un errore non gestito. - Gli errori in callback future. Callback di
setTimeout, listener degli eventi,Promisecreate dentro altrePromise.
Per quest’ultimo caso, se devi associare un contesto a un errore asincrono senza spargere try-catch ovunque, l’errore va gestito alla sorgente:
function conContesto(fn, contesto) {
return (...args) =>
Promise.resolve()
.then(() => fn(...args))
.catch(errore => {
console.error(`[${contesto}]`, errore);
throw errore;
});
}Conclusione
Gli errori asincroni sono subdoli: silenziosi, imprevedibili, difficili da tracciare. Il costrutto try-catch è lo strumento giusto per la maggior parte dei casi, ma da solo non è una barriera universale: non intercetta gli errori HTTP senza un controllo esplicito di res.ok, non intercetta le Promise che non hai atteso e non copre le callback future.
La regola pratica è mettere il catch dove il problema può verificarsi, distinguere le famiglie di errore invece di trattarle tutte allo stesso modo, e non fidarsi dell’aspetto robusto del codice senza averlo visto fallire. Solo così si affronta davvero la natura caotica del JavaScript asincrono.