Async/await vs Promises in JavaScript: Confronto Approfondito e Casi d'Uso

Intermedio
JavaScript

Esplora le differenze fondamentali tra Promises e Async/await in JavaScript, comprendendo come entrambi gestiscono l'asincronia e quando utilizzare l'uno o l'altro per scrivere codice più pulito ed efficiente.

Pubblicato
Tag
Web Development javascript async await promises asincronia Error Handling concurrency

Async/await vs Promises in JavaScript: Confronto Approfondito e Casi d'Uso

Il mondo dello sviluppo web è intrinsecamente asincrono. Operazioni come il recupero di dati da un server, la lettura di file o l'interazione con un database richiedono tempo e non possono bloccare l'esecuzione del thread principale, pena il congelamento dell'interfaccia utente e un'esperienza utente pessima. Per anni, JavaScript ha affrontato questa sfida con vari meccanismi, evolvendosi dai callback a soluzioni più eleganti come le Promises e, più recentemente, la sintassi async/await.

Questo articolo si propone di esplorare in profondità sia le Promises che async/await, confrontandone i punti di forza e di debolezza, e fornendo linee guida chiare su quando e come utilizzarli efficacemente nella programmazione web moderna. L'obiettivo è non solo capire il "cosa" ma anche il "perché", per poter prendere decisioni architetturali informate e scrivere codice JavaScript robusto e leggibile.

L'Evoluzione dell'Asincronia in JavaScript: Dai Callback alle Promises

Prima di addentrarci nel confronto, è fondamentale comprendere il contesto storico. Inizialmente, JavaScript gestiva l'asincronia principalmente tramite callback. Una funzione di callback è semplicemente una funzione che viene passata come argomento a un'altra funzione e viene eseguita una volta completata l'operazione asincrona. Sebbene funzionali, i callback portavano a problemi noti come la "callback hell" (o "pyramid of doom"), dove il codice diventava profondamente annidato e difficilissimo da leggere, mantenere e debuggare.

La Nascita delle Promises

Le Promises sono state introdotte per risolvere i problemi legati ai callback annidati. Una Promise è un oggetto che rappresenta il completamento (o il fallimento) futuro di un'operazione asincrona. In altre parole, è un contenitore per un valore che potrebbe non essere ancora disponibile.

Una Promise può trovarsi in uno dei seguenti stati:

  • Pending: Lo stato iniziale; l'operazione asincrona non è ancora completata.
  • Fulfilled (o Resolved): L'operazione asincrona è stata completata con successo e la Promise ha un valore risultante.
  • Rejected: L'operazione asincrona è fallita e la Promise ha un motivo di errore.

Una volta che una Promise passa dallo stato pending a fulfilled o rejected, si dice che è "settled" e il suo stato non può più cambiare. Questo garantisce che una Promise risolva o rigetti una sola volta.

Creare e Usare le Promises

Per creare una Promise, si usa il costruttore new Promise(), che accetta una funzione esecutore con due argomenti: resolve e reject.

const fetchData = new Promise((resolve, reject) => {
  // Simulazione di un'operazione asincrona (es. fetch API)
  setTimeout(() => {
    const success = true; // Simula il successo o il fallimento
    if (success) {
      resolve('Dati recuperati con successo!');
    } else {
      reject('Errore nel recupero dei dati.');
    }
  }, 2000);
});

fetchData
  .then(data => {
    console.log(data); // 'Dati recuperati con successo!'
  })
  .catch(error => {
    console.error(error); // 'Errore nel recupero dei dati.'
  })
  .finally(() => {
    console.log('Operazione completata, indipendentemente dal successo o fallimento.');
  });

In questo esempio, fetchData è una Promise che si risolve dopo 2 secondi. Il metodo .then() viene chiamato quando la Promise è fulfilled, mentre .catch() viene chiamato in caso di rejected. .finally() viene sempre chiamato, indipendentemente dall'esito, per eseguire operazioni di pulizia.

Incatenamento di Promises

Il vero potere delle Promises risiede nella loro capacità di essere incatenate. Ogni chiamata a .then() restituisce una nuova Promise, permettendo di eseguire una sequenza di operazioni asincrone in modo lineare e leggibile.

function step1() {
  return new Promise(resolve => setTimeout(() => {
    console.log('Passo 1 completato');
    resolve(10);
  }, 1000));
}

function step2(value) {
  return new Promise(resolve => setTimeout(() => {
    console.log(`Passo 2 completato con valore: ${value}`);
    resolve(value * 2);
  }, 1000));
}

function step3(value) {
  return new Promise((resolve, reject) => setTimeout(() => {
    console.log(`Passo 3 completato con valore: ${value}`);
    if (value > 15) {
      resolve('Successo finale!');
    } else {
      reject('Fallimento: valore troppo basso.');
    }
  }, 1000));
}

step1()
  .then(result1 => step2(result1))
  .then(result2 => step3(result2))
  .then(finalResult => {
    console.log('Risultato finale:', finalResult);
  })
  .catch(error => {
    console.error('Errore nella catena:', error);
  });

Questo pattern risolve la callback hell, rendendo il flusso asincrono più gestibile. Esistono anche metodi utili come Promise.all() per eseguire più Promises in parallelo e attendere che tutte si risolvano, o Promise.race() per attendere la prima che si risolve o rigetta.

Async/await: La Rivoluzione Sintattica

Nonostante le Promises abbiano migliorato notevolmente la gestione dell'asincronia, il codice poteva ancora apparire leggermente verboso, soprattutto con l'incatenamento di molti .then(). Qui entra in gioco async/await, introdotto in ECMAScript 2017. async/await è fondamentalmente syntactic sugar sopra le Promises, il che significa che non introduce un nuovo meccanismo di asincronia, ma fornisce una sintassi più pulita e leggibile per lavorare con esse.

L'obiettivo principale di async/await è rendere il codice asincrono simile al codice sincrono, migliorando drasticamente la leggibilità e la facilità di ragionamento sul flusso di esecuzione.

La Keyword async

Una funzione dichiarata con la keyword async restituisce implicitamente una Promise. Ciò significa che è possibile chiamare .then() e .catch() su di essa, proprio come su qualsiasi altra Promise. All'interno di una funzione async, è possibile utilizzare la keyword await.

async function myAsyncFunction() {
  return 'Ciao dal futuro!';
}

myAsyncFunction().then(message => console.log(message)); // 'Ciao dal futuro!'

La Keyword await

La keyword await può essere utilizzata solo all'interno di una funzione async. Essa "pausa" l'esecuzione della funzione async fino a quando la Promise su cui è applicata non viene risolta. Una volta risolta, await restituisce il valore risolto della Promise e la funzione async riprende la sua esecuzione.

function simulateFetch(data, delay = 1000) {
  return new Promise(resolve => setTimeout(() => resolve(data), delay));
}

async function getProcessedData() {
  console.log('Inizio recupero dati...');
  const rawData = await simulateFetch('Dati grezzi dal server');
  console.log('Dati grezzi ricevuti:', rawData);

  const processedData = await simulateFetch(`${rawData} e processati`, 500);
  console.log('Dati processati:', processedData);

  return processedData;
}

getProcessedData()
  .then(finalResult => console.log('Risultato finale:', finalResult))
  .catch(error => console.error('Errore:', error));

console.log('Questa riga viene eseguita subito dopo la chiamata a getProcessedData.');

Notate come console.log('Questa riga...') venga eseguita immediatamente, dimostrando che la funzione async è non bloccante. Tuttavia, all'interno di getProcessedData, il flusso sembra sincrono.

Gestione degli Errori con try...catch

Uno dei maggiori vantaggi di async/await è la gestione degli errori, che diventa molto più simile alla gestione degli errori sincroni grazie ai blocchi try...catch. Qualsiasi Promise rigettata all'interno di un blocco try verrà catturata dal blocco catch più vicino.

async function fetchDataWithError() {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject('Errore di rete!'), 1500);
  });
}

async function getDataReliably() {
  try {
    console.log('Tentativo di recupero dati...');
    const data = await fetchDataWithError();
    console.log('Dati:', data);
  } catch (error) {
    console.error('Si è verificato un errore:', error);
  } finally {
    console.log('Operazione di recupero dati terminata.');
  }
}

getDataReliably();

Questo approccio è molto più intuitivo rispetto all'incatenamento di .catch() con le Promises.

Confronto Dettagliato: Promises vs Async/await

Ora che abbiamo compreso le basi di entrambi, analizziamo le differenze chiave e i vantaggi di ciascuno.

Leggibilità e Manutenzione

  • Promises: Migliora la leggibilità rispetto ai callback, ma le catene .then() possono diventare lunghe e a volte un po' difficili da seguire, soprattutto con logica condizionale complessa. Il codice sembra ancora 'asincrono'.
  • Async/await: Rende il codice asincrono quasi indistinguibile dal codice sincrono. Questo è il suo punto di forza principale, migliorando drasticamente la leggibilità e riducendo il carico cognitivo per comprendere il flusso di esecuzione. Il codice è più lineare e intuitivo.

Gestione degli Errori

  • Promises: Richiede .catch() alla fine della catena o per ogni singola Promise. Dimenticare un .catch() può portare a errori non gestiti (unhandled promise rejections).
  • Async/await: Utilizza i tradizionali blocchi try...catch, che sono familiari a chiunque abbia esperienza con linguaggi di programmazione sincroni. Questo rende la gestione degli errori più naturale e meno soggetta a dimenticanze.

Debugging

  • Promises: Il debugging delle catene di Promises può essere complesso. Gli stack trace possono essere meno informativi, poiché la catena di esecuzione è interrotta da microtask queue e non è un unico flusso sincrono.
  • Async/await: Il debugging è notevolmente semplificato. Poiché il codice assomiglia a quello sincrono, gli stack trace sono spesso più chiari e l'utilizzo dei breakpoint nei debugger funziona in modo più intuitivo, permettendo di "step-through" il codice asincrono come se fosse sincrono.

Parallelismo

Entrambi possono gestire operazioni parallele, ma con sintassi diverse.

  • Promises: Promise.all() e Promise.race() sono i metodi standard per eseguire più Promises in parallelo. Promise.all() attende che tutte le Promises si risolvano, mentre Promise.race() attende la prima che si risolve o rigetta.

    Promise.all([
      fetch('/api/users'),
      fetch('/api/products'),
      fetch('/api/orders')
    ])
    .then(responses => Promise.all(responses.map(res => res.json())))
    .then(data => {
      const [users, products, orders] = data;
      console.log('Tutti i dati recuperati:', { users, products, orders });
    })
    .catch(error => console.error('Errore nel recupero parallelo:', error));
    
  • Async/await: Anche con async/await, si utilizza Promise.all() per il parallelismo, ma la sintassi per "awaitare" il risultato è più pulita.

    async function fetchAllData() {
      try {
        const [usersResponse, productsResponse, ordersResponse] = await Promise.all([
          fetch('/api/users'),
          fetch('/api/products'),
          fetch('/api/orders')
        ]);
    
        const [users, products, orders] = await Promise.all([
          usersResponse.json(),
          productsResponse.json(),
          ordersResponse.json()
        ]);
    
        console.log('Tutti i dati recuperati:', { users, products, orders });
      } catch (error) {
        console.error('Errore nel recupero parallelo con async/await:', error);
      }
    }
    
    fetchAllData();
    

    Come si può vedere, la logica di Promise.all rimane, ma l'assegnazione e la gestione del risultato sono più concise.

Compatibilità

  • Promises: Supportate nativamente dalla maggior parte dei browser moderni e ambienti Node.js. Richiedono un polyfill per browser molto vecchi (es. IE).
  • Async/await: Supportato dalla maggior parte dei browser moderni e Node.js (dalla versione 7.6 in poi). Anche per async/await potrebbero essere necessari transpiler (come Babel) per supportare ambienti legacy, ma il supporto nativo è ormai molto diffuso.

Esempi Pratici e Casi d'Uso

Vediamo alcuni scenari comuni e come gestirli con entrambi gli approcci.

Caso d'Uso 1: Recupero Dati da un'API con Logica Condizionale

Immaginiamo di voler recuperare i dettagli di un utente e, se l'utente è un amministratore, recuperare anche la lista dei suoi permessi.

Con Promises

function getUser(userId) {
  return fetch(`/api/users/${userId}`).then(res => res.json());
}

function getPermissions(userId) {
  return fetch(`/api/admin/permissions/${userId}`).then(res => res.json());
}

const userId = 123;
getUser(userId)
  .then(user => {
    console.log('Dettagli utente:', user);
    if (user.isAdmin) {
      return getPermissions(userId).then(permissions => {
        console.log('Permessi amministratore:', permissions);
        return { user, permissions }; // Restituisce un oggetto combinato
      });
    } else {
      return { user }; // Restituisce solo l'utente
    }
  })
  .then(data => {
    console.log('Operazione completata:', data);
  })
  .catch(error => {
    console.error('Errore:', error);
  });

Con Async/await

async function getUserAndPermissions(userId) {
  try {
    const user = await getUser(userId);
    console.log('Dettagli utente:', user);

    let permissions = null;
    if (user.isAdmin) {
      permissions = await getPermissions(userId);
      console.log('Permessi amministratore:', permissions);
    }

    return { user, permissions };
  } catch (error) {
    console.error('Errore nel recupero dati:', error);
    throw error; // Rilancia l'errore per essere gestito a un livello superiore se necessario
  }
}

// Chiamata alla funzione
getUserAndPermissions(123)
  .then(data => console.log('Operazione completata:', data))
  .catch(err => console.error('Gestione errore esterno:', err));

La versione async/await è notevolmente più pulita e facile da seguire, specialmente all'interno del blocco condizionale if (user.isAdmin). Il flusso logico è lineare, senza annidamenti profondi.

Caso d'Uso 2: Sequenza di Operazioni con Dipendenze

Immaginiamo di dover creare un nuovo utente, poi creare un profilo per quell'utente, e infine assegnare un ruolo predefinito, dove ogni passo dipende dal precedente.

Con Promises

function createUser(userData) {
  return fetch('/api/users', { method: 'POST', body: JSON.stringify(userData) })
         .then(res => res.json());
}

function createProfile(userId, profileData) {
  return fetch(`/api/users/${userId}/profile`, { method: 'POST', body: JSON.stringify(profileData) })
         .then(res => res.json());
}

function assignRole(userId, roleId) {
  return fetch(`/api/users/${userId}/roles`, { method: 'POST', body: JSON.stringify({ roleId }) })
         .then(res => res.json());
}

const newUser = { name: 'Mario Rossi', email: 'mario@example.com' };

createUser(newUser)
  .then(user => {
    console.log('Utente creato:', user);
    return createProfile(user.id, { bio: 'Nuovo sviluppatore.' });
  })
  .then(profile => {
    console.log('Profilo creato:', profile);
    return assignRole(profile.userId, 'developer');
  })
  .then(roleAssignment => {
    console.log('Ruolo assegnato:', roleAssignment);
    console.log('Setup utente completato!');
  })
  .catch(error => {
    console.error('Errore durante il setup dell\\'utente:', error);
  });

Con Async/await

async function setupNewUser(userData, profileData, roleId) {
  try {
    console.log('Creazione utente...');
    const user = await createUser(userData);
    console.log('Utente creato:', user);

    console.log('Creazione profilo...');
    const profile = await createProfile(user.id, profileData);
    console.log('Profilo creato:', profile);

    console.log('Assegnazione ruolo...');
    const roleAssignment = await assignRole(user.id, roleId);
    console.log('Ruolo assegnato:', roleAssignment);

    console.log('Setup utente completato!');
    return { user, profile, roleAssignment };
  } catch (error) {
    console.error('Errore critico nel setup utente:', error);
    // Qui si potrebbe aggiungere logica per rollback o notifica
    throw error; // Rilancia l'errore per gestione esterna
  }
}

const newUser = { name: 'Luigi Bianchi', email: 'luigi@example.com' };
const newProfile = { bio: 'Sviluppatore front-end.' };

setupNewUser(newUser, newProfile, 'frontend-dev')
  .then(result => console.log('Risultato finale:', result))
  .catch(err => console.error('Gestione errore globale:', err));

Ancora una volta, la versione async/await è più diretta e facile da leggere, simulando un flusso di esecuzione sincrono, ma senza bloccare il thread.

Errori Comuni e Best Practices

L'adozione di async/await porta molti vantaggi, ma è importante essere consapevoli di alcune insidie.

1. Dimenticare await in una funzione async

Questo è un errore comune. Se chiami una funzione che restituisce una Promise all'interno di una funzione async ma dimentichi await, la Promise non verrà attesa e il tuo codice continuerà a essere eseguito con un valore pending o undefined.

async function doSomethingWrong() {
  const result = simulateFetch('Dati importanti'); // Manca await qui!
  console.log(result); // Potrebbe stampare una Promise { <pending> }
}

doSomethingWrong();

Soluzione: Assicurati sempre di utilizzare await quando intendi attendere il risultato di una Promise.

async function doSomethingRight() {
  const result = await simulateFetch('Dati importanti');
  console.log(result); // Stampa 'Dati importanti'
}

doSomethingRight();

2. Non Gestire gli Errori

Anche con async/await, è cruciale gestire gli errori. Dimenticare un blocco try...catch in una funzione async farà sì che un errore rigettato si propaghi e possa causare un "unhandled promise rejection" a livello globale, crashando potenzialmente l'applicazione Node.js o mostrando errori nella console del browser.

async function riskyOperation() {
  return new Promise((_, reject) => setTimeout(() => reject('Disastro!'), 500));
}

async function callRiskyOperationWithoutCatch() {
  await riskyOperation(); // L'errore non è gestito qui
  console.log('Questa riga non verrà mai raggiunta se c\\'è un errore.');
}

// Questo causerà un UnhandledPromiseRejectionWarning in Node.js
// o un errore nella console del browser.
callRiskyOperationWithoutCatch();

Soluzione: Avvolgi le operazioni await in un try...catch.

async function callRiskyOperationWithCatch() {
  try {
    await riskyOperation();
    console.log('Operazione riuscita.');
  } catch (error) {
    console.error('Errore gestito:', error);
  }
}

callRiskyOperationWithCatch();

3. Abuso di await (Bloccare Inutilmente l'Esecuzione)

Non tutte le operazioni asincrone devono essere eseguite in sequenza. Se hai due operazioni indipendenti che possono essere eseguite in parallelo, non await-arle una dopo l'altra.

async function fetchSequentially() {
  console.time('Sequential Fetch');
  const users = await fetch('/api/users').then(res => res.json()); // Attende 1
  const products = await fetch('/api/products').then(res => res.json()); // Attende 2
  console.timeEnd('Sequential Fetch'); // Tempo totale = tempo_utenti + tempo_prodotti
  return { users, products };
}

fetchSequentially();

Soluzione: Usa Promise.all() per eseguire operazioni parallele.

async function fetchInParallel() {
  console.time('Parallel Fetch');
  const [usersResponse, productsResponse] = await Promise.all([
    fetch('/api/users'),
    fetch('/api/products')
  ]);

  const users = await usersResponse.json();
  const products = await productsResponse.json();

  console.timeEnd('Parallel Fetch'); // Tempo totale = max(tempo_utenti, tempo_prodotti)
  return { users, products };
}

fetchInParallel();

4. Non Usare async su una Funzione che Contiene await

Ricorda che await può essere usato solo all'interno di una funzione async. Se provi a usarlo in una funzione normale, otterrai un SyntaxError.

function regularFunction() {
  // await simulateFetch('Test'); // SyntaxError: await is only valid in async functions
}

Soluzione: Dichiara la funzione come async.

async function asyncFunction() {
  await simulateFetch('Test');
}

Quando Usare Cosa?

Sebbene async/await sia generalmente preferito per la sua leggibilità e facilità di gestione degli errori, ci sono contesti in cui le Promises "pure" potrebbero ancora avere un senso, o dove la comprensione delle loro basi è cruciale.

  • Preferisci async/await nella maggior parte dei casi: Per nuove applicazioni e per refactorizzare codice esistente, async/await è la scelta migliore. Rende il codice più pulito, più facile da leggere, debuggare e mantenere. È particolarmente utile quando il flusso di controllo è sequenziale o include logica condizionale complessa.
  • Usa Promise.all() (o Promise.race()) con async/await per il parallelismo: Come mostrato negli esempi, async/await non rimpiazza Promise.all() per l'esecuzione parallela di operazioni indipendenti. Si combinano magnificamente.
  • Quando lavori con API che restituiscono Promises: Poiché async/await è costruito sulle Promises, continuerai a lavorare con oggetti Promise restituiti da funzioni (come fetch o le API basate su Promise di Node.js). await è semplicemente un modo più comodo per estrarre il loro valore.
  • Per scopi didattici o in librerie/framework che espongono un'API Promise-based: Se stai creando una libreria o un'API che deve esporre un comportamento asincrono, potresti voler restituire direttamente Promises, per dare agli utilizzatori la flessibilità di usare .then()/.catch() o async/await a loro discrezione.

In sintesi, async/await è lo strumento più moderno e potente per gestire l'asincronia in JavaScript, ma è essenziale comprendere le Promises come il suo fondamento. Non sono in competizione, ma piuttosto evoluzioni complementari dello stesso concetto.

Prossimi Passi

Per approfondire ulteriormente la tua comprensione dell'asincronia in JavaScript, considera i seguenti argomenti:

  1. Event Loop di JavaScript: Comprendere come funziona l'Event Loop è fondamentale per capire veramente l'asincronia, le microtask queue e come JavaScript gestisce le operazioni non bloccanti.
  2. Generators: Le funzioni generatore (function*) sono un concetto avanzato che ha preceduto async/await e su cui async/await è, in parte, basato a livello di implementazione. Capirle può offrire una prospettiva più profonda.
  3. Streams: Per la gestione di grandi quantità di dati o flussi continui, le API Streams (disponibili sia nel browser che in Node.js) offrono un approccio efficiente all'elaborazione asincrona.
  4. Web Workers: Per operazioni computazionalmente intensive che devono essere eseguite in un thread separato per non bloccare l'UI, i Web Workers sono la soluzione.
  5. Librerie di gestione dello stato asincrono: In framework come React o Vue, esistono pattern e librerie specifiche (es. React Query, SWR, VueUse) che semplificano ulteriormente la gestione di dati asincroni, caching e stati di caricamento/errore.