Le Promise sono diventate il modo standard per gestire l'asincronia in JavaScript, ma nella pratica quotidiana si usano spesso solo le forme più semplici: una chiamata fetch, qualche await in sequenza, forse un Promise.all per parallelizzare due richieste. Quando i requisiti si fanno più complessi — operazioni che devono potersi annullare, logiche di coordinamento tra più promise, gestione di errori selettiva — il modello base mostra i suoi limiti. Questo articolo esplora proprio quei casi.
Coordinare più promise: oltre Promise.all
Promise.all è lo strumento più noto per eseguire più operazioni in parallelo e attendere che tutte completino. Il suo comportamento in caso di errore, però, è spesso frainteso: basta che una sola promise venga rigettata perché l'intero gruppo fallisca immediatamente, ignorando le altre ancora in corso. Questo è il cosiddetto "fail-fast", utile in molti contesti ma non in tutti.
Quando si vuole sapere l'esito di tutte le operazioni indipendentemente dal fatto che alcune falliscano, la scelta giusta è Promise.allSettled. Introdotto in ES2020, restituisce un array di oggetti che descrivono lo stato finale di ciascuna promise: ogni elemento ha una proprietà status che vale "fulfilled" o "rejected", e rispettivamente una proprietà value o reason. È il pattern ideale per operazioni indipendenti dove ogni risultato ha valore autonomo, come salvare dati su più servizi in parallelo.
const operazioni = [salvasuDB(dati), inviaEmail(utente), aggiornaCache(chiave)];
const esiti = await Promise.allSettled(operazioni);
esiti.forEach(esito => {
if (esito.status === 'fulfilled') {
console.log('Completato:', esito.value);
} else {
console.warn('Fallito:', esito.reason);
}
});
All'estremo opposto c'è Promise.race, che risolve o rigetta non appena la prima promise si conclude. È utile per implementare timeout: si mette in gara la richiesta reale con una promise che rigetta dopo un certo numero di millisecondi. Chiunque arrivi primo determina il risultato. Lo stesso principio, ma applicato solo ai successi, si ottiene con Promise.any, che ignora i rigetti e si risolve con il primo valore positivo disponibile tra tutte le promise in gara.
Cancellazione con AbortController
Una delle frustrazioni più comuni con le Promise è che non supportano la cancellazione nativamente. Una volta avviata, una promise non può essere interrotta dall'esterno. Il browser ha risolto questo problema per le API web attraverso AbortController, un meccanismo standardizzato che permette di segnalare la cancellazione a qualsiasi operazione che sappia ascoltarla.
Il funzionamento è semplice: si crea un'istanza di AbortController, dalla quale si estrae un oggetto signal. Questo signal viene passato alle operazioni che supportano la cancellazione, come fetch. Quando si chiama controller.abort(), il signal si attiva e tutte le operazioni che lo osservano vengono interrotte, rigettando le rispettive promise con un errore di tipo AbortError.
// L'utente naviga via dopo 2 secondi
setTimeout(() => controller.abort(), 2000);
try {
const risposta = await fetch('/api/dati-pesanti', {
signal: controller.signal
});
const dati = await risposta.json();
} catch (err) {
if (err.name === 'AbortError') {
console.log('Richiesta annullata intenzionalmente');
} else {
throw err; // rilancia gli errori reali
}
}
Propagare la cancellazione in funzioni personalizzate
La vera potenza di AbortController emerge quando si costruisce logica applicativa che rispetta il segnale di cancellazione a ogni livello, non solo nella chiamata fetch. Si può passare il signal attraverso tutta la catena di funzioni e controllarne lo stato prima di ogni operazione significativa. Il signal espone una proprietà aborted che diventa true nel momento in cui abort() viene chiamato, e un evento abort a cui è possibile agganciarsi.
async function elaboraDati(id, signal) {
// Controllo esplicito prima di ogni fase costosa
if (signal.aborted) throw new DOMException('Annullato', 'AbortError');
const dati = await fetch(`/api/${id}`, { signal });
if (signal.aborted) throw new DOMException('Annullato', 'AbortError');
// Elaborazione sincrona costosa
return trasforma(await dati.json());
}
// Utilizzo con timeout automatico
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const risultato = await elaboraDati(42, controller.signal);
clearTimeout(timeout);
return risultato;
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
Gestione selettiva degli errori nelle catene
Un errore comune nelle catene di promise è usare un unico blocco catch in fondo che gestisce indistintamente qualsiasi errore. Questo funziona, ma perde la granularità: non si sa quale operazione ha fallito, né si può reagire diversamente a errori diversi. Un approccio più robusto intercetta gli errori nel punto in cui si producono, decidendo lì se recuperare o rilanciarli.
async function caricaProfilo(userId) {
// Errore di rete: proviamo con dati cached
const dati = await fetch(`/api/utenti/${userId}`)
.catch(() => fetch(`/cache/utenti/${userId}`));
// Errore di parsing: preferiamo un oggetto vuoto a un crash
const profilo = await dati.json()
.catch(() => ({ nome: 'Sconosciuto', id: userId }));
// Questo errore invece deve propagarsi: è critico
const permessi = await caricaPermessi(userId);
return { profilo, permessi };
}
Intercettare l'errore direttamente sulla promise che può fallire, invece di catturarlo globalmente, rende il comportamento esplicito e verificabile. Ogni catch comunica un'intenzione precisa: "se questa specifica operazione fallisce, fai così". Il codice diventa più facile da leggere, da testare e da modificare.
Il pattern della promise con timeout
Combinando Promise.race con AbortController si ottiene un pattern completo per operazioni con scadenza: la richiesta viene annullata davvero, non solo ignorata. La differenza è importante perché senza annullamento esplicito la richiesta originale continua in background, consumando risorse di rete e potenzialmente scrivendo in uno stato dell'applicazione che non esiste più.
function conTimeout(promiseFn, ms) {
const controller = new AbortController();
const timeout = new Promise((_, reject) =>
setTimeout(() => {
controller.abort();
reject(new Error(`Timeout dopo ${ms}ms`));
}, ms)
);
return Promise.race([promiseFn(controller.signal), timeout]);
}
// Utilizzo
const dati = await conTimeout(
signal => fetch('/api/report', { signal }),
3000
);
Questo approccio incapsula tutta la logica di timeout in una funzione riutilizzabile. Chi la chiama non deve gestire né il controller né il timer: riceve semplicemente una promise che si risolve entro il limite di tempo o rigetta con un errore chiaro.