Il tuo codice JavaScript è una piramide di callback? Ogni chiamata AJAX aggiunge un livello di indentazione, e quando arrivi al terzo livello non capisci più nulla? Lo abbiamo visto decine di volte nei progetti che ci arrivano: codice asincrono scritto come se fossimo nel 2010. E il problema non è solo estetico: più callback annidati, più possibilità di errori silenziosi, più difficoltà a gestire fallimenti.
Noi, di Meteora Web, lavoriamo con JavaScript ogni giorno – dalle personalizzazioni WooCommerce alle piattaforme Laravel/Vue. Gestire l'asincronia in modo pulito non è un lusso: è quello che separa un applicativo che si mantiene da uno che dopo tre mesi va riscritto. In questa guida ti mostriamo come passare dalle callback alle Promise e poi a async/await, con una gestione degli errori robusta.
Dal callback hell alle Promise: perché cambia tutto
Immagina di dover caricare i dati di un utente, poi il suo ordine, poi il dettaglio del prodotto. Con le callback scrivevi:
getUser(id, function(user) {
getOrder(user.id, function(order) {
getProduct(order.productId, function(product) {
console.log(product.name);
}, function(err) {
console.error('Errore prodotto', err);
});
}, function(err) {
console.error('Errore ordine', err);
});
}, function(err) {
console.error('Errore utente', err);
});
Tre livelli di callback, tre gestori di errore separati, e se dimentichi uno di quei function(err) l'errore passa inosservato. Non è solo brutto: è fragile.
Le Promise risolvono il problema dandoti una catena lineare. Ogni operazione asincrona restituisce un oggetto Promise che può essere risolto (resolve) o rifiutato (reject).
getUser(id)
.then(user => getOrder(user.id))
.then(order => getProduct(order.productId))
.then(product => console.log(product.name))
.catch(err => console.error('Errore nella catena', err));
Un unico catch finale gestisce qualsiasi errore lungo la catena. Se getUser fallisce, salta direttamente al catch. È lineare, prevedibile, mantenibile.
Creare una Promise da zero
Capire come creare una Promise ti aiuta a usare librerie e API con consapevolezza. Ecco un esempio reale: simuliamo una chiamata a un'API con fetch, ma avvolta in una Promise per avere più controllo:
function fetchWithTimeout(url, timeout = 5000) {
return new Promise((resolve, reject) => {
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
reject(new Error('Timeout superato'));
}, timeout);
fetch(url, { signal: controller.signal })
.then(response => {
clearTimeout(timer);
if (!response.ok) {
reject(new Error(`HTTP ${response.status}`));
} else {
resolve(response.json());
}
})
.catch(err => {
clearTimeout(timer);
reject(err);
});
});
}
Questa funzione restituisce una Promise: se la richiesta va a buon fine, resolve con i dati JSON; altrimenti reject con un errore. Il timeout impedisce che la richiesta rimanga in stallo per sempre – problema comune nelle app che dimenticano di gestire i timeout di rete.
async/await: codice asincrono che sembra sincrono
Le Promise hanno reso il codice leggibile, ma la sintassi then/catch può ancora risultare verbosa. Con async/await scrivi codice che si legge come se fosse sincrono, ma rimane asincrono.
async function loadProductData(userId) {
try {
const user = await getUser(userId);
const order = await getOrder(user.id);
const product = await getProduct(order.productId);
console.log(product.name);
} catch (error) {
console.error('Errore nel caricamento', error);
}
}
Ogni await mette in pausa l'esecuzione della funzione fino a quando la Promise non viene risolta o rifiutata. La funzione stessa è dichiarata async, quindi restituisce sempre una Promise. Questo permette di usare try/catch per gestire gli errori – esattamente come faresti con codice sincrono.
Errore comune: dimenticare il try/catch
Molti sviluppatori scrivono:
async function load() {
const data = await fetch('/api'); // se fetch fallisce, qui si scatena un'eccezione non gestita
// ...
}
Se fetch lancia un errore (rete down, JSON malformato), l'eccezione non viene catturata e la Promise restituita da load() viene rifiutata senza che nessuno la gestisca. Risultato: errore silenzioso o crash dell'applicazione. **Usa sempre un try/catch attorno a await**, oppure concatenalo con .catch() se preferisci.
Error Handling avanzato: retry, fallback e cancellazione
Nel nostro lavoro su piattaforme e-commerce e applicativi web, la gestione degli errori non si ferma al semplice log. Un errore di rete può essere temporaneo; una richiesta fallita può essere ritentata. Ecco una funzione di retry generica:
async function fetchWithRetry(url, retries = 3, delay = 1000) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (i === retries - 1) throw error; // ultimo tentativo, rilancia
console.warn(`Tentativo ${i+1} fallito. Riprovo tra ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
Questa funzione tenta fino a 3 volte, con un intervallo di 1 secondo. Se alla fine fallisce, l'errore originale viene propagato – e potrà essere gestito dal chiamante. Nei nostri progetti usiamo pattern simili per chiamate API verso fornitori esterni (gateway di pagamento, servizi di spedizione) che possono temporaneamente non rispondere.
Fallback a dati locali
Un altro pattern utile: se una chiamata fallisce, mostri dati cached o fallback. Esempio:
async function loadProductList() {
try {
const data = await fetchFromAPI('/products');
cache.set('products', data);
return data;
} catch {
const cached = cache.get('products');
if (cached) {
console.warn('Usando dati in cache');
return cached;
}
throw new Error('Nessun dato disponibile');
}
}
In questo modo l'utente non vede una pagina bianca se il server è momentaneamente irraggiungibile. Lo abbiamo usato per un cliente e-commerce che gestiva il catalogo con un ERP lento: la cache locale permetteva comunque di navigare i prodotti mentre i dati si aggiornavano in background.
Cancellare una Promise in esecuzione
A volte serve interrompere una richiesta asincrona in corso – per esempio se l'utente cambia pagina. Con AbortController puoi farlo:
async function searchProducts(query, signal) {
const response = await fetch(`/api/search?q=${query}`, { signal });
return response.json();
}
// Uso
const controller = new AbortController();
searchProducts('scarpe', controller.signal).catch(err => {
if (err.name === 'AbortError') {
console.log('Richiesta annullata');
} else {
console.error('Errore', err);
}
});
// Quando serve annullare:
controller.abort();
Questo evita che risposte tardive vadano a sovrascrivere dati più recenti – un problema comune nelle UI di ricerca.
Esempio completo: gestione asincrona di un form di contatto
Mettiamo insieme tutto. Un form di contatto che invia dati a un'API, gestisce errori di rete e mostra feedback all'utente.
class ContactForm {
constructor(formElement) {
this.form = formElement;
this.submitBtn = formElement.querySelector('[type="submit"]');
this.form.addEventListener('submit', this.handleSubmit.bind(this));
}
async handleSubmit(event) {
event.preventDefault();
this.setLoading(true);
try {
const formData = new FormData(this.form);
const data = await this.sendData(formData);
this.showSuccess('Messaggio inviato con successo!');
this.form.reset();
} catch (error) {
this.showError(error.message || 'Errore imprevisto, riprova più tardi.');
} finally {
this.setLoading(false);
}
}
async sendData(formData) {
const controller = new AbortController();
// Timeout di 10 secondi
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch('/api/contact', {
method: 'POST',
body: formData,
signal: controller.signal
});
clearTimeout(timeout);
if (!response.ok) {
const errorBody = await response.json().catch(() => null);
throw new Error(errorBody?.message || `Errore server (${response.status})`);
}
return response.json();
} catch (error) {
clearTimeout(timeout);
if (error.name === 'AbortError') {
throw new Error('Richiesta troppo lenta. Riprova.');
}
throw error;
}
}
setLoading(loading) {
this.submitBtn.disabled = loading;
this.submitBtn.textContent = loading ? 'Invio in corso...' : 'Invia messaggio';
}
showSuccess(msg) { /* mostra messaggio verde */ }
showError(msg) { /* mostra messaggio rosso */ }
}
Nota: il finally assicura che il bottone torni abilitato anche in caso di errore. Il timeout con AbortController evita attese infinite. E l'estrazione dell'errore dal corpo della risposta permette di mostrare messaggi utili (es. "campo email non valido") invece di un generico "HTTP 400".
In sintesi — cosa fare adesso
- Riscrivi le tue callback in Promise. Se hai ancora codice con annidamenti, convertilo in catene
.then(). La documentazione MDN sulle Promise è il punto di partenza. - Adotta async/await per leggibilità. Ogni funzione che usa
awaitdeve essereasynce deve gestire errori contry/catcho.catch()finale. - Aggiungi retry e timeout. Le chiamate di rete non sono affidabili. Implementa almeno un timeout (con
AbortController) e, per operazioni critiche, un retry con backoff. - Non lasciare Promises in sospeso. Se una funzione
asyncpuò lanciare eccezioni, assicurati che il chiamante gestisca il rifiuto. Altrimenti ottieni un errore non gestito che può mandare in crash l'app Node.js o restare silenzioso nel browser. - Testa gli scenari di errore. Simula timeout, errori HTTP, JSON malformati. Se il tuo codice asincrono non viene testato con errori reali, non sai se regge.
Noi, di Meteora Web, applichiamo questi pattern ogni giorno nei nostri progetti – dalla gestione delle richieste AJAX in WooCommerce alle API di piattaforme Laravel/Vue. L'asincronia ben gestita non è solo questione di codice elegante: è affidabilità per i tuoi utenti e minori costi di manutenzione per te.
Sponsored Protocol