Esegui questo snippet in Node o nella console del browser:

1
2
3
4
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");

L’output è 1 4 3 2. Un setTimeout con un ritardo di 0ms viene eseguito comunque dopo il callback della promise, ed entrambi partono solo dopo che tutto il codice sincrono, incluso quello scritto più avanti, è terminato. async/await si basa su questo stesso ordine, non introduce un meccanismo nuovo. La maggior parte dei comportamenti sorprendenti nel JavaScript asincrono nasce dal trattare await come se sospendesse l’intero programma, quando in realtà sospende solo la funzione in cui si trova.

Perché setTimeout viene eseguito dopo una promise senza ritardo

JavaScript esegue il tuo codice su un solo thread con un solo call stack. Le funzioni vengono impilate quando vengono chiamate e rimosse quando ritornano, e nulla altro viene eseguito finché lo stack contiene qualcosa. Timer, risposte di rete e risoluzioni di promise sono gestiti dal runtime attorno allo stack (il browser, o libuv in Node), che accoda un callback per dopo.

La coda in cui finisce il callback dipende dal tipo di lavoro:

  • La task queue (chiamata anche macrotask queue) contiene i callback di setTimeout e setInterval, i completamenti di I/O e gli eventi della UI. L’event loop preleva esattamente un task da questa coda per ogni iterazione.
  • La microtask queue contiene i callback delle promise (.then, .catch, .finally) e qualsiasi cosa pianificata con queueMicrotask. L’event loop vuota completamente questa coda, incluse le microtask aggiunte mentre la sta vuotando, prima di procedere.

Un’iterazione del loop è così:

1
2
3
4
5
6
esegui tutto il codice sincrono finché il call stack è vuoto
  -> vuota completamente la microtask queue (le nuove microtask saltano la stessa fila)
    -> esegui esattamente un task dalla task queue
      -> vuota di nuovo la microtask queue
        -> (solo nel browser) repaint se necessario
          -> ripeti

Nello snippet sopra, console.log("4") chiude l’esecuzione sincrona, la microtask queue si vuota e stampa 3, e solo a quel punto il loop preleva il callback del timer che stampa 2. Se hai già lavorato con una coda di messaggi, il modello ti è familiare: il lavoro aspetta in fila e un solo consumer gestisce un elemento alla volta. La particolarità è che JavaScript ha due file, e quella delle microtask viene sempre servita prima.

Un effetto collaterale da conoscere: dato che la microtask queue deve essere vuota prima che un task possa partire, una catena di promise che continua a pianificare altri callback affama i timer e, nel browser, blocca i repaint.

Dove await sospende davvero l’esecuzione

Una funzione async restituisce sempre una promise, anche senza return e senza await nel corpo. await sospende quella singola funzione: tutto quello che viene dopo diventa una continuazione che viene eseguita come microtask non appena il valore atteso si risolve. È zucchero sintattico sopra .then(), non un meccanismo diverso.

1
2
3
4
5
6
7
8
9
async function example() {
  console.log("a");
  await null;
  console.log("b");
}

console.log("start");
example();
console.log("end");

Questo stampa start, a, end, b. example() viene eseguita in modo sincrono fino ad await null, poi restituisce il controllo al chiamante. console.log("end") viene eseguito prima che example riprenda, perché la ripresa viene accodata come microtask e le microtask aspettano che il codice sincrono corrente finisca.

Questo unico fatto, await cede il controllo alla microtask queue e nient’altro, spiega tutti gli errori qui sotto. È anche il motivo per cui async/await ha soppiantato le catene di .then() annidate: una catena che gestisce gli errori a tre livelli diversi è il flusso di controllo aggrovigliato che lo spaghetti code descrive, e una sequenza di await rimette l’ordine delle operazioni sulla pagina, dall’alto verso il basso.

Cosa succede se dimentichi la parola await

Togli un await e ottieni l’oggetto promise invece del suo risultato. Nessun errore, solo dati sbagliati tre righe più sotto:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

async function printUser(id) {
  const user = getUser(id); // await mancante
  console.log(user.name);
}

printUser(4821); // "undefined" — user è una Promise, non l'oggetto risolto

getUser(4821) ritorna immediatamente una Promise pendente, e una Promise non ha una proprietà name. La correzione è una sola parola: const user = await getUser(id). Quello che rende questo bug costoso è il silenzio. Niente lancia un errore sulla riga incriminata; trovi undefined più avanti nel codice, oppure [object Promise] in una template string.

Meglio await in un loop o Promise.all?

await dentro un ciclo for completa ogni iterazione prima di iniziare la successiva, anche quando le iterazioni non dipendono l’una dall’altra:

1
2
3
4
5
6
7
async function loadUsers(ids) {
  const users = [];
  for (const id of ids) {
    users.push(await getUser(id)); // una richiesta alla volta
  }
  return users;
}

Se ogni chiamata a getUser richiede 200ms e ids ha tre elementi, questo codice richiede circa 600ms. La seconda richiesta non parte finché non torna la risposta della prima. Quando le richieste sono indipendenti, avviale tutte insieme e aspetta il gruppo:

1
2
3
async function loadUsers(ids) {
  return Promise.all(ids.map((id) => getUser(id)));
}

Ora tutte e tre le richieste partono insieme e la funzione ritorna in circa 200ms, il tempo della più lenta. Promise.all viene rigettata non appena una singola promise fallisce. Se ti serve ogni risultato indipendentemente dai singoli fallimenti, Promise.allSettled ti dà uno stato per ciascuna invece di interrompersi al primo errore.

L’await sequenziale è la scelta giusta in due casi. Primo: ogni iterazione ha bisogno del risultato precedente, come quando pagini un’API con un cursore preso dall’ultima risposta. Secondo: stai deliberatamente limitando le richieste verso un endpoint con rate limit che rigetterebbe un’ondata di chiamate concorrenti. Con un array ids grande, Promise.all può causare da solo questo secondo problema, quindi limita la concorrenza con un pool piccolo o con una libreria come p-limit.

Perché async forEach non aspetta

forEach chiama il suo callback per ogni elemento e scarta qualsiasi cosa il callback restituisca, promise incluse:

1
2
3
4
5
6
7
function printAll(ids) {
  ids.forEach(async (id) => {
    const user = await getUser(id);
    console.log(user.name);
  });
  console.log("done");
}

"done" viene stampato prima di qualsiasi nome utente. forEach avvia tutti e tre i callback async e ritorna senza aspettare nessuno di loro, e non puoi fare await su di esso perché ritorna undefined, non una promise. Per lavoro sequenziale, usa for...of con await all’interno. Per lavoro concorrente, usa la stessa correzione del loop qui sopra:

1
2
3
4
async function printAll(ids) {
  const users = await Promise.all(ids.map((id) => getUser(id)));
  users.forEach((user) => console.log(user.name));
}

Funziona perché map restituisce un array di promise, esattamente quello che Promise.all si aspetta.

Perché una promise rejection non gestita fa crashare Node

Una funzione async che lancia un errore produce una promise rigettata, non un’eccezione nello stack frame del chiamante:

1
2
3
4
5
async function risky() {
  throw new Error("boom");
}

risky(); // chiamata senza .catch e senza try/catch attorno

In una tab del browser questo stampa Uncaught (in promise) nella console e la pagina continua a funzionare. Node è più severo: da Node 15, una rejection non gestita viene trattata come un’eccezione non gestita per impostazione predefinita, e il processo termina. Gestisci le rejection dove accadono, con try/catch attorno all’await o con un .catch() sulla chiamata. Poi, come rete di sicurezza finale, aggiungi un handler globale:

1
2
3
process.on("unhandledRejection", (reason) => {
  console.error("unhandled rejection:", reason);
});

Registrare questo listener sostituisce il crash predefinito di Node, quindi il processo continua a funzionare anche dopo una rejection che nessun altro codice gestisce. Usalo per loggare e poi terminare in modo controllato (chiama process.exit(1) dopo aver scaricato i log), non per aggirare la mancanza di handler.

Perché try/catch non intercetta gli errori nei callback

Un try/catch vede solo le eccezioni lanciate mentre il suo blocco è in esecuzione. Il codice pianificato per dopo, in un altro task o microtask, è fuori portata anche se scritto sulla riga successiva:

1
2
3
4
5
6
7
try {
  setTimeout(() => {
    throw new Error("late");
  }, 100);
} catch (err) {
  console.log("caught:", err.message); // non viene mai eseguito
}

setTimeout pianifica il callback e ritorna, quindi il blocco try finisce immediatamente. Quando il callback lancia l’errore 100ms dopo, viene eseguito su un call stack nuovo senza nessun catch sopra.

await è diverso. La funzione async viene sospesa, non terminata: il suo stato locale e il suo blocco try vengono salvati, e quando la promise attesa viene rigettata, la funzione riprende lanciando la rejection alla riga dell’await, dentro il try:

1
2
3
4
5
6
7
8
async function loadUser(id) {
  try {
    return await getUser(id);
  } catch (err) {
    console.error(`failed to load user ${id}:`, err.message);
    throw err;
  }
}

Nota il return await. Con un semplice return getUser(id), la funzione restituisce direttamente la promise senza aspettarla, e una rejection salta completamente questo catch.

Come scovare questi bug prima di andare in produzione

La maggior parte di questi errori è rilevabile staticamente, quindi lascia che sia il linter a trovarli invece dell’event loop. In ESLint, attiva no-await-in-loop per segnalare gli await sequenziali (poi disattivalo riga per riga dove la sequenzialità è voluta) e no-async-promise-executor. Se usi TypeScript, aggiungi @typescript-eslint/no-floating-promises e @typescript-eslint/no-misused-promises: il primo intercetta le promise che non vengono né attese né gestite, il secondo intercetta i callback async passati a funzioni come forEach che ignorano la promise restituita. Quando l’output esce in un ordine diverso da quello che ti aspettavi, scrivi in che coda finisce ogni callback prima di toccare il codice.

Articoli correlati