Pourquoi le JavaScript asynchrone existe
Les ordinateurs effectuent certaines tâches lentement : lire des fichiers, interroger des bases de données ou attendre la réponse d’un serveur. Si JavaScript s’arrêtait pour attendre chaque opération, la page gèlerait et les utilisateurs quitteraient le site. La programmation asynchrone est la méthode qui permet au langage de continuer à fonctionner pendant que des tâches lentes s’exécutent ailleurs.
Le modèle mental à adopter est celui d’un restaurant. Un seul serveur prend votre commande, la transmet à la cuisine, puis sert immédiatement une autre table. Lorsque votre plat est prêt, la cuisine sonne une cloche et le serveur vous l’apporte. Le serveur ne reste jamais dans la cuisine à regarder la nourriture cuire. JavaScript est ce serveur, et l’event loop est la cloche.
La pile d’appels et la boucle d’événements
JavaScript possède une seule call stack (pile d’appels). Chaque appel de fonction pousse une frame ; lorsqu’elle retourne, la frame est dépilée. Comme il n’y a qu’une seule pile, une seule opération peut s’exécuter à la fois.
Les API asynchrones sont fournies par l’environnement hôte, et non par le langage lui-même. Lorsque vous appelez setTimeout, fetch ou une lecture de fichier, le navigateur ou Node.js gère l’attente. Une fois le travail terminé, son callback est placé dans une file d’attente. L’event loop (boucle d’événements) vérifie constamment : la call stack est-elle vide ? Si oui, elle récupère le callback suivant de la file et l’exécute.
// order.js
console.log("start");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("end");
// start, end, promise, timeout
Deux détails sont importants. Premièrement, le code synchrone se termine toujours avant l’exécution de tout callback. Deuxièmement, il existe deux files d’attente : les microtasks (callbacks de promesses) s’exécutent immédiatement après la tâche actuelle, avant les tasks (timers, E/S). C’est pourquoi promise s’affiche dans les logs avant timeout.
Callbacks
La méthode originelle pour gérer les résultats asynchrones est le callback — une fonction que l’on passe en argument pour qu’elle soit appelée plus tard.
// callback.js
function getUser(id, callback) {
setTimeout(() => {
callback({ id, name: "Ada" });
}, 300);
}
getUser(1, (user) => {
console.log(user.name);
});
Les callbacks fonctionnent, mais ils se composent mal. Lorsque chaque étape dépend de la précédente, on se retrouve à imbriquer les fonctions, ce qui mène au callback hell : un code qui s’étire en diagonale sur l’écran et rend la gestion des erreurs pénible.
// hell.js
getUser(1, (user) => {
getPosts(user.id, (posts) => {
getComments(posts[0].id, (comments) => {
// three levels deep and counting
});
});
});
Promises
Une Promise est un objet qui représente une valeur qui peut ne pas encore exister. Elle se trouve dans l’un des trois états suivants :
- pending — l’opération est toujours en cours.
- fulfilled — l’opération s’est terminée avec succès et a retourné une valeur.
- rejected — l’opération a échoué pour une raison donnée.
Une fois stabilisée (settled), une promise ne change plus jamais d’état. Vous y attachez des gestionnaires via then, catch et finally.
// promise.js
function getUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) resolve({ id, name: "Ada" });
else reject(new Error("Invalid id"));
}, 300);
});
}
getUser(1)
.then((user) => console.log(user.name))
.catch((error) => console.error(error.message))
.finally(() => console.log("done"));
Le chaînage est son principal avantage. Chaque then retourne une nouvelle promise ; ainsi, au lieu d’imbriquer vos appels, vous pouvez aplatir la séquence et gérer toutes les erreurs dans un seul catch à la fin.
// chain.js
getUser(1)
.then((user) => getPosts(user.id))
.then((posts) => getComments(posts[0].id))
.then((comments) => console.log(comments))
.catch((error) => console.error("Something failed:", error));
Combiner des promesses
Lorsque plusieurs opérations asynchrones sont impliquées, le constructeur Promise propose des combinateurs.
// combine.js
const [user, posts, settings] = await Promise.all([
getUser(1),
getPosts(1),
getSettings(),
]);
const results = await Promise.allSettled([getUser(1), getUser(-1)]);
const fastest = await Promise.race([getFromCache(), getFromNetwork()]);
const firstSuccess = await Promise.any([mirrorA(), mirrorB()]);
Promise.all exécute tout en parallèle et rejette si l’une des promesses est rejetée. Promise.allSettled attend la fin de toutes les opérations et rapporte chaque résultat. Promise.race se termine dès que la première promesse est résolue ou rejetée, et Promise.any se résout avec la première promesse réussie. Le choix du combinateur modifie radicalement la gestion de vos erreurs.
async et await
async et await sont des syntaxes construites au-dessus des promesses. Marquer une fonction comme async fait en sorte qu’elle retourne toujours une promesse. À l’intérieur, await met la fonction en pause jusqu’à ce qu’une promesse soit résolue, puis reprend avec la valeur résolue — sans bloquer le reste du programme.
// await.js
async function loadDashboard() {
try {
const user = await getUser(1);
const posts = await getPosts(user.id);
return { user, posts };
} catch (error) {
console.error("Failed to load dashboard", error);
throw error;
}
}
Cela se lit comme du code synchrone, et c’est tout l’intérêt. Les erreurs sont gérées avec des blocs try/catch classiques, et finally continue de s’exécuter. La fonction retournant une promesse, les appelants peuvent l’await ou chaîner des .then comme d’habitude.
Séquentiel vs parallèle
Une erreur subtile mais coûteuse consiste à attendre des tâches indépendantes une par une. Chaque await attend la précédente, le temps total est donc la somme de toutes les attentes. Lorsque les tâches ne dépendent pas les unes des autres, lancez-les simultanément.
// parallel.js
// Sequential — slow, ~600ms
const a = await slowTask("a");
const b = await slowTask("b");
// Parallel — fast, ~300ms
const [x, y] = await Promise.all([slowTask("x"), slowTask("y")]);
N’utilisez des await séquentiels que lorsqu’une étape ultérieure a réellement besoin du résultat d’une étape précédente, comme récupérer un utilisateur avant de récupérer les posts de cet utilisateur.
Gestion des erreurs dans le code asynchrone
Les promesses rejetées qui ne sont traitées par personne deviennent des unhandled rejections, ce qui peut faire planter les processus Node.js et encombrer la console dans les navigateurs.
// errors.js
try {
const res = await fetch("/api/data");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
} catch (error) {
showMessage("Could not load data. Please try again.");
} finally {
hideSpinner();
}
Privilégiez l’utilisation de try/catch autour des appels attendus avec await, lancez des objets Error (et non des chaînes de caractères), et traitez toujours l’erreur ou relancez-la délibérément. Le guide de gestion des erreurs approfondit les stratégies et la communication des messages aux utilisateurs.
Pièges courants
- Oublier
await. La fonction retourne une promesse et non la valeur, donc la ligne suivante s’exécute trop tôt. forEachavec des callbacks asynchrones.forEachignore les promesses retournées ; utilisezfor...ofouPromise.allavecmap.- Utiliser await dans une boucle alors que le parallélisme suffirait. Cela fonctionne, mais c’est inutilement lent.
- Ne pas retourner de valeur à l’intérieur de
then. L’absence dereturnbrise la chaîne et fait perdre la valeur. - Supposer l’ordre d’exécution. Seuls
awaitet l’ordonnancement des microtâches garantissent la séquence ; les tâches concurrentes se terminent quand elles le peuvent. - Ignorer les erreurs avec un
catchvide, masquant ainsi le bug que vous auriez dû identifier.
Bonnes pratiques
- Privilégiez
async/awaitaux longues chaînes de.thenpour une meilleure lisibilité. - Exécutez les tâches indépendantes en parallèle avec
Promise.allouPromise.allSettled. - Gérez toujours les rejets, ne serait-ce que pour les journaliser et les relancer.
- Gardez vos fonctions async courtes et assignez-leur une seule responsabilité.
- Utilisez
Promise.allSettledlorsque des résultats partiels sont acceptables. - Annulez le travail dont vous n’avez plus besoin avec un
AbortController. - Affichez des états de chargement et d’erreur pour que les utilisateurs comprennent ce qu’il se passe.
Et après ?
Vous disposez désormais de l’ensemble des outils asynchrones. Mettez-les en pratique avec l’ Fetch API, où les promesses et await pilotent chaque requête réseau, et renforcez votre code en appliquant les modèles de Gestion des erreurs.