Maîtriser l'Event Loop et l'Asynchronisme Avancé en JavaScript
Plongez dans les mécanismes internes de JavaScript pour comprendre comment le moteur gère l'exécution asynchrone, optimiser vos performances et déboguer les problèmes les plus subtils. Un cours pour développeurs qui veulent passer maître dans la gestion du temps d'exécution.
L'Event Loop : Le Cœur du JavaScript Asynchrone
Définition : L'Event Loop est le mécanisme fondamental qui permet à JavaScript, langage single-threaded, de gérer les opérations asynchrones en surveillant continuellement la Call Stack et la Task Queue, décidant quand exécuter les callbacks et promises.
Explication : JavaScript fonctionne avec un modèle d'exécution particulier. Le moteur dispose d'une Call Stack (pile d'appels) où s'exécutent les fonctions de manière synchrone. Cependant, certaines opérations comme les requêtes réseau, les timers ou les événements DOM ne bloquent pas ce flux. Elles sont gérées par les Web APIs du navigateur ou le runtime Node.js. L'Event Loop est le gardien qui vérifie constamment si la Call Stack est vide, et si c'est le cas, elle extrait les tâches en attente des différentes queues (Microtask Queue pour les Promises et Mutation Observers, Macrotask Queue pour setTimeout, setInterval, etc.) et les exécute.
L'ordre d'exécution est crucial : toutes les microtâches s'exécutent avant la prochaine macrotâche. Cela peut créer des situations surprenantes si on ne comprend pas ce mécanisme. Par exemple, une Promise résolvée s'exécutera avant un setTimeout même s'il est déclaré en premier dans le code.
console.log('1. Début');
setTimeout(() => {
console.log('2. setTimeout (Macrotask)');
}, 0);
Promise.resolve()
.then(() => {
console.log('3. Promise (Microtask)');
return Promise.resolve();
})
.then(() => {
console.log('4. Promise 2 (Microtask)');
});
console.log('5. Fin');
// Résultat attendu :
// 1. Début
// 5. Fin
// 3. Promise (Microtask)
// 4. Promise 2 (Microtask)
// 2. setTimeout (Macrotask)
| Concept | Call Stack | Microtask Queue | Macrotask Queue |
|---|---|---|---|
| Type | Synchrone | Asynchrone (haute priorité) | Asynchrone (basse priorité) |
| Contenu | Fonctions en cours | Promises, MutationObserver | setTimeout, setInterval, I/O |
| Exécution | Immédiat | Après Call Stack, avant Macrotask | Après toutes Microtasks |
| Bloquant | Oui | Non | Non |
Astuce : Pour déboguer l'Event Loop, utilisez console.trace() avec des timestamps pour visualiser l'ordre exact d'exécution. Inscrivez-vous également aux événements du navigateur avec performance.mark() et performance.measure() pour profiler précisément.
Attention : Ne confondez pas l'ordre logique de votre code avec l'ordre d'exécution réel. Une erreur classique est de supposer que deux Promises s'exécutent immédiatement : elles s'ajoutent à la Microtask Queue et attendent que la Call Stack soit vide.
Les Promises : Construction, Chaînage et Pièges
Définition : Une Promise est un objet représentant l'achèvement (ou l'échec) futur d'une opération asynchrone et de sa valeur résultante. Elle possède trois états : pending, fulfilled, ou rejected, et est immutable une fois résolue.
Explication : Les Promises offrent une meilleure structure que les callbacks pour gérer l'asynchronisme. Contrairement aux callbacks qui peuvent créer une "callback hell" difficile à lire, les Promises permettent un chaînage lisible avec .then() et .catch(). Cependant, comprendre les subtilités est crucial pour éviter les bugs insidieux.
Une Promise en attente (pending) peut passer à l'état fulfilled (avec une valeur) ou rejected (avec une raison d'erreur). Cet état est immutable : une fois changé, il ne peut pas revenir. C'est un point important pour les performances : les Promises ne peuvent jamais "rechanger d'avis".
Le chaînage de Promises est puissant mais demande de la précision. Chaque appel à .then() retourne une NOUVELLE Promise, ce qui permet de continuer la chaîne. Si vous retournez une valeur dans .then(), elle devient la résolution de la Promise suivante. Si vous retournez une Promise, la Promise suivante attendra sa résolution.
// Exemple de construction et pièges
const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Succès!');
}, 1000);
});
// Chaînage basique
myPromise
.then(result => {
console.log(result); // "Succès!"
return result.toUpperCase();
})
.then(result => {
console.log(result); // "SUCCÈS!"
})
.catch(error => {
console.error('Erreur:', error);
});
// Piège courant : oublier de retourner une Promise
const badChain = Promise.resolve(1)
.then(x => {
Promise.resolve(x * 2); // ❌ Pas de retour !
})
.then(x => {
console.log(x); // undefined au lieu de 2
});
// Correct :
const goodChain = Promise.resolve(1)
.then(x => {
return Promise.resolve(x * 2); // ✅ Avec retour
})
.then(x => {
console.log(x); // 2
});
| Méthode Promise | Utilité | Comportement |
|---|---|---|
.then(onFulfilled, onRejected) |
Chaîner les opérations | Retourne une nouvelle Promise |
.catch(onRejected) |
Gérer les erreurs | Équivalent à .then(null, handler) |
.finally(onFinally) |
Nettoyer après résolution/rejet | Exécuté toujours, retourne Promise |
Promise.all(iterable) |
Attendre toutes les Promises | Rejette si une seule échoue |
Promise.race(iterable) |
Prendre la première | Résout/rejette avec la première |
Promise.allSettled() |
Résultats de toutes | Jamais rejetée, retourne statuts |
Promise.any() |
Première Promise réussie | Rejette si toutes échouent |
Astuce : Utilisez Promise.allSettled() pour garantir que votre code continue même si certaines Promises échouent. C'est particulièrement utile pour les requêtes batch où vous voulez voir tous les résultats indépendamment des erreurs.
Attention : Les erreurs dans les Promises ne s'affichent pas toujours. Une Promise rejetée sans .catch() génère une "unhandled rejection" qui peut passer inaperçue. Implémentez toujours un gestionnaire d'erreur global :
process.on('unhandledRejection', (reason, promise) => {
console.error('Rejection non gérée:', reason);
});
Async/Await : Syntaxe Sucre et Pièges de Performance
Définition : Async/Await est une syntaxe moderne qui permet d'écrire du code asynchrone de manière qui ressemble à du code synchrone, en utilisant les mots-clés async et await pour gérer les Promises de façon plus lisible.
Explication : Async/Await transforme fondamentalement comment on écrit le code asynchrone. Une fonction async retourne toujours une Promise. Le mot-clé await pause l'exécution jusqu'à ce que la Promise soit résolue, puis retourne sa valeur. Cela rend le code beaucoup plus facile à lire et à maintenir par rapport au chaînage de .then().
Cependant, il y a des pièges majeurs de performance. Le plus courant est le "await séquentiel" quand vous pourriez faire du parallèle. Si vous avez plusieurs opérations indépendantes, les attendre une par une au lieu de les lancer toutes ensemble peut multipliier le temps d'exécution. De plus, une erreur non gérée dans une fonction async rejette sa Promise retournée, ce qui peut être difficile à déboguer.
Autre point critique : await dans une boucle. Si vous avez besoin d'attendre une opération pour chaque élément d'un tableau, faire for...of avec await sera très lent. Utiliser Promise.all() avec map() est exponentiellement plus rapide.
// ❌ MAUVAIS : Exécution séquentielle (lent)
async function slowFetch() {
const user = await fetch('/api/user').then(r => r.json());
const posts = await fetch(`/api/posts/${user.id}`).then(r => r.json());
const comments = await fetch(`/api/comments`).then(r => r.json());
return { user, posts, comments };
// Temps total ≈ 3s (si chaque requête = 1s)
}
// ✅ BON : Exécution parallèle (rapide)
async function fastFetch() {
const [user, posts, comments] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts/1').then(r => r.json()),
fetch('/api/comments').then(r => r.json())
]);
return { user, posts, comments };
// Temps total ≈ 1s (exécution parallèle)
}
// ❌ MAUVAIS : await dans une boucle
async function slowLoop() {
const ids = [1, 2, 3, 4, 5];
for (const id of ids) {
await fetch(`/api/item/${id}`); // Attend chaque requête
}
// Temps total ≈ 5s
}
// ✅ BON : Paralléliser avec Promise.all
async function fastLoop() {
const ids = [1, 2, 3, 4, 5];
await Promise.all(
ids.map(id => fetch(`/api/item/${id}`))
);
// Temps total ≈ 1s
}
// Gestion des erreurs
async function withErrorHandling() {
try {
const data = await fetch('/api/data').then(r => r.json());
return data;
} catch (error) {
console.error('Erreur réseau:', error);
throw error; // Re-lance pour que l'appelant gère
}
}
| Pattern | Temps (5 requêtes) | Cas d'usage | Risque |
|---|---|---|---|
await séquentiel (for...of) |
~5s | Dépendances entre requêtes | Très lent si pas de dépendance |
Promise.all() (parallèle) |
~1s | Opérations indépendantes | Rejette si une seule échoue |
Promise.allSettled() |
~1s | Résultats indépendants | Plus lent que .all() |
Chaînage .then() |
Variable | Contrôle granulaire | Difficile à lire |
Astuce : Pour déboguer les problèmes async/await, activez "Pause on Uncaught Exceptions" dans les DevTools et placez un debugger; stratégiquement. Utilisez également console.time() et console.timeEnd() pour profiler les segments async.
Attention : Une erreur dans une fonction async qui n'est pas catchée va silencieusement rejeter la Promise retournée. Si vous appelez une fonction async sans .catch() ou sans await avec try/catch, l'erreur peut passer inaperçue. Toujours gérer les erreurs explicitement ou avoir un handler global.
Gestion Avancée des Erreurs Asynchrones
Définition : La gestion avancée des erreurs asynchrones comprend les stratégies pour capturer, logger, propager et récupérer des erreurs dans un environnement où les opérations s'exécutent hors du flux synchrone normal, incluant les stack traces perdues et les erreurs masquées.
Explication : Les erreurs asynchrones sont particulièrement traîtres parce qu'elles peuvent être perdues dans les mécanismes de l'Event Loop. Quand une erreur se produit dans une Promise rejetée, si aucun .catch() n'est attaché, JavaScript génère une "unhandled rejection" qui peut passer inaperçue, surtout en production.
En JavaScript moderne, il existe plusieurs couches d'erreurs : les erreurs synchrones (try/catch), les rejections de Promises (catch ou try/catch avec await), et les erreurs non gérées (global handlers). Comprendre cette hiérarchie et mettre en place des défenses en profondeur est essentiel.
Les stack traces constituent un autre défi majeur. Quand une erreur se produit dans une Promise chaînée, la stack trace peut être fragmentée ou perdue. Les DevTools modernes les reconstituisent partiellement avec "Async Stack Traces", mais c'est une fonctionnalité optionnelle et peut impacter les performances.
Pour la production, il faut mettre en place un système centralisé de logging qui capture non seulement le message d'erreur, mais aussi le contexte (requête HTTP, user ID, timestamp, etc.) et les stack traces complètes. Des outils comme Sentry ou DataDog aident énormément.
// Stratégie 1 : Handler global pour unhandled rejections
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', {
reason,
promise,
timestamp: new Date().toISOString(),
stack: reason?.stack || 'No stack trace'
});
// Envoyer à un service de logging
logToSentry(reason);
});
// Stratégie 2 : Wrapper pour transformer les erreurs
function asyncHandler(fn) {
return async (...args) => {
try {
return await fn(...args);
} catch (error) {
console.error('Erreur dans asyncHandler:', error);
// Enrichir l'erreur avec du contexte
error.context = { args, timestamp: Date.now() };
throw error; // Re-lever pour que l'appelant puisse aussi gérer
}
};
}
// Stratégie 3 : Promise timeout pour éviter les hangs infinis
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
)
]);
}
// Utilisation
const fastFetch = withTimeout(
fetch('/api/slow-endpoint'),
5000
);
// Stratégie 4 : Retry avec exponential backoff
async function retryAsync(fn, retries = 3, delay = 1000) {
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (error) {
if (i === retries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i)));
console.log(`Retry ${i + 1}/${retries} après ${delay * Math.pow(2, i)}ms`);
}
}
}
// Stratégie 5 : Logging contextualisé avec async hooks (Node.js)
const asyncHooks = require('async_hooks');
const fs = require('fs');
let contextes = new Map();
const hook = asyncHooks.createHook({
init: (asyncId, type, triggerAsyncId) => {
contextes.set(asyncId, { type, triggerAsyncId, startTime: Date.now() });
},
destroy: (asyncId) => {
contextes.delete(asyncId);
}
});
hook.enable();
| Technique | Couverture | Performance | Complexité |
|---|---|---|---|
| try/catch avec await | Erreurs synchrones + Promises | Zéro overhead | Basse |
.catch() sur chaque Promise |
Granulaire | Minime | Moyenne |
Handler global unhandledRejection |
Filet de sécurité | Zéro overhead | Basse |
| Wrapper asyncHandler | Contrôle centralisé | Légère | Moyenne |
| Timeout + Retry | Robustesse réseau | Moyenne | Moyenne-Haute |
Astuce : Utilisez la méthode .finally() combinée avec des handlers globaux. Le .finally() est exécuté qu'il y ait succès ou erreur, idéal pour nettoyer les ressources (fermer des connexions, arrêter des spinners, etc.).
Attention : Ne supprimez jamais les erreurs silencieusement avec un .catch() vide. C'est un anti-pattern qui cache les vrais problèmes. Si vous capturez une erreur, soit logez-la, soit traitez-la, soit relancez-la enrichie. Aussi, les async hooks en Node.js peuvent significativement ralentir l'application s'ils sont trop verbeux.
La Microtask Queue et l'Optimisation des Microtasks
Définition : La Microtask Queue est une queue de priorité haute exécutée après chaque macrotâche et avant la prochaine, contenant les Promises, MutationObservers et queueMicrotask(), permettant une optimisation fine du timing d'exécution.
Explication : Comprendre la distinction entre Microtask Queue et Macrotask Queue est fondamental pour optimiser les performances et déboguer les comportements inattendus. Les microtâches (Promises, MutationObserver, queueMicrotask) s'exécutent TOUTES avant la prochaine macrotâche (setTimeout, setInterval, requestAnimationFrame, I/O).
Cela crée une hiérarchie d'exécution : Call Stack synchrone → Microtask Queue (toutes les microtâches) → Rendu du navigateur (si nécessaire) → Macrotask Queue (une seule macrotâche) → retour à Microtask Queue.
Une conséquence importante : si vous avez une longue série de Promises chaînées, elles vont tous bloquer la Macrotask Queue, ce qui peut retarder les rendre et créer des jank (saccades) visuelles. C'est un problème classique de performance qui passe souvent inaperçu.
Inversement, si une opération très rapide doit s'exécuter après une macrotâche, utiliser une Promise est plus approprié que setTimeout qui a un délai minimum (~4ms).
// Démonstration de la hiérarchie
console.log('1. Sync');
setTimeout(() => {
console.log('2. setTimeout (macrotask)');
Promise.resolve().then(() => {
console.log('3. Promise après setTimeout (microtask)');
});
}, 0);
Promise.resolve()
.then(() => {
console.log('4. Promise (microtask)');
return Promise.resolve();
})
.then(() => {
console.log('5. Promise chaîné (microtask)');
});
queueMicrotask(() => {
console.log('6. queueMicrotask (microtask)');
});
console.log('7. Sync fin');
// Résultat :
// 1. Sync
// 7. Sync fin
// 4. Promise (microtask)
// 5. Promise chaîné (microtask)
// 6. queueMicrotask (microtask)
// 2. setTimeout (macrotask)
// 3. Promise après setTimeout (microtask)
// Piège : Longue chaîne de Promises bloquant le rendu
async function longMicrotaskChain() {
for (let i = 0; i < 10000; i++) {
await Promise.resolve(); // Chaque await = nouvelle microtâche
}
}
// ✅ Meilleur : Découper avec setTimeout pour laisser respirer le moteur
async function optimizedMicrotaskChain() {
for (let i = 0; i < 10000; i++) {
if (i % 100 === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
} else {
await Promise.resolve();
}
}
}
// Utiliser queueMicrotask pour du code ultra-rapide
function efficientUIUpdate() {
// Mettre à jour le DOM
element.textContent = 'Mise à jour';
// Exécuter du code UI-related rapidement après la mise à jour
queueMicrotask(() => {
// Ceci s'exécute immédiatement après, avant le rendu
console.log('DOM update queued');
});
}
// Profiler le timing exact
function profilMicrotasks() {
console.time('microtask-test');
let count = 0;
function countMicrotask() {
count++;
if (count < 100) {
queueMicrotask(countMicrotask);
} else {
console.timeEnd('microtask-test');
console.log(`${count} microtasks en ${performance.now()}ms`);
}
}
countMicrotask();
}
| Queue | Priorité | Contenu | Bloque rendu | Timing min |
|---|---|---|---|---|
| Call Stack (Sync) | Critique | Code synchrone | Oui | Immédiat |
| Microtask Queue | Très haute | Promises, queueMicrotask | Oui | < 1ms |
| Render | Haute | Paint, Composite | Non | 16.67ms (60fps) |
| Macrotask Queue | Moyenne | setTimeout, I/O | Oui | 1-10ms selon navigateur |
Astuce : Utilisez performance.mark() et performance.measure() autour de chaînes longues de Promises pour identifier si elles causent des jank. Le DevTools Performance tab montre clairement les microtasks qui dépassent 16ms (saccade visible).
Attention : Ne pas confondre "microtâche rapide" avec "gratuit en terme de performance". Mettre 10 000 microtâches dans la queue va still bloquer le rendu pendant potentiellement plusieurs centaines de millisecondes. Il faut découper avec des macrotâches (setTimeout) pour laisser respirer le navigateur.
Debugging Expert et Outils de Profilage
Définition : Le debugging expert d'code asynchrone comprend l'utilisation avancée des DevTools, des profilers, et des techniques spéciales pour tracer, mesurer et identifier les goulots d'étranglement dans les opérations asynchrones complexes.
Explication : Déboguer du code asynchrone est notoriement plus difficile que du code synchrone parce que la stack trace est fragmentée et le contexte est perdu entre l'ajout de la tâche à la queue et son exécution réelle. Les DevTools modernes ont considérablement amélioré la situation avec les "Async Stack Traces" et les DevTools expérimentaux.
Il existe plusieurs niveaux de debugging : 1) Basique avec console.log et breakpoints, 2) Intermédiaire avec les Performance DevTools, 3) Expert avec les Chrome DevTools avancées et Node.js inspection protocol.
Pour Node.js, l'inspection via node --inspect permet de connecter les DevTools Chrome comme un véritable débuggeur IDE. Pour le navigateur, les Performance DevTools montrent exactement quand chaque macrotâche et microtâche s'exécute, ce qui est invaluable pour identifier les jank.
Un outil souvent oublié mais puissant est performance.measure() combiné avec User Timing API. Cela permet de marquer des points dans le code et de mesurer les intervalles avec précision au microseconde près, tout en étant visible dans les DevTools.
// 1. Async Stack Traces (Chrome DevTools)
// Activer dans DevTools > Settings > Experiments > Async Stack Traces
// Puis les breakpoints vont afficher la stack complète
// 2. Profiler performant avec User Timing API
class AsyncProfiler {
constructor(name) {
this.name = name;
this.marks = {};
}
mark(phase) {
const markName = `${this.name}-${phase}`;
performance.mark(markName);
this.marks[phase] = markName;
}
measure(from, to) {
const measureName = `${this.name}-${from}-to-${to}`;
try {
performance.measure(measureName, this.marks[from], this.marks[to]);
const measure = performance.getEntriesByName(measureName)[0];
return measure.duration;
} catch (e) {
console.error(`Impossible de mesurer ${measureName}`, e);
return null;
}
}
report() {
const measures = performance.getEntriesByType('measure')
.filter(m => m.name.startsWith(this.name));
console.table(measures.map(m => ({
phase: m.name,
duration: `${m.duration.toFixed(2)}ms`,
startTime: `${m.startTime.toFixed(2)}ms`
})));
}
}
// Utilisation
async function tracedAsyncOperation() {
const profiler = new AsyncProfiler('api-call');
profiler.mark('start');
try {
const response = await fetch('/api/data');
profiler.mark('fetch-complete');
const data = await response.json();
profiler.mark('parse-complete');
// Mesurer chaque phase
console.log('Fetch duration:', profiler.measure('start', 'fetch-complete'));
console.log('Parse duration:', profiler.measure('fetch-complete', 'parse-complete'));
return data;
} finally {
profiler.report();
}
}
// 3. Node.js debugging avec inspector
// Lancer : node --inspect app.js
// Puis ouvrir chrome://inspect
// 4. Décorateur pour auto-logging des fonctions async
function debugAsync(target, propertyKey, descriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function(...args) {
const startTime = performance.now();
const methodName = propertyKey;
console.group(`[Async] ${methodName} called`);
console.log('Arguments:', args);
console.time(methodName);
try {
const result = await originalMethod.apply(this, args);
console.log('Result:', result);
return result;
} catch (error) {
console.error('Error:', error);
throw error;
} finally {
console.timeEnd(methodName);
console.log(`Duration: ${(performance.now() - startTime).toFixed(2)}ms`);
console.groupEnd();
}
};
return descriptor;
}
// Utilisation du décorateur
class DataService {
@debugAsync
async fetchUser(userId) {
const response = await fetch(`/api/users/${userId}`);
return response.json();
}
}
// 5. Observer les rejections non gérées en temps réel
class RejectionMonitor {
constructor() {
this.rejections = [];
this.setupListeners();
}
setupListeners() {
// Node.js
if (typeof process !== 'undefined') {
process.on('unhandledRejection', (reason, promise) => {
this.record('Node.js', reason, promise);
});
}
// Navigateur
if (typeof window !== 'undefined') {
window.addEventListener('unhandledrejection', (event) => {
this.record('Browser', event.reason, event.promise);
// Optionnel: empêcher le comportement par défaut
// event.preventDefault();
});
}
}
record(context, reason, promise) {
const entry = {
context,
timestamp: new Date().toISOString(),
reason: reason instanceof Error ? reason.message : String(reason),
stack: reason?.stack || 'No stack trace',
promise
};
this.rejections.push(entry);
console.warn('[RejectionMonitor]', entry);
}
report() {
console.table(this.rejections.map(r => ({
context: r.context,
timestamp: r.timestamp,
reason: r.reason
})));
}
}
const monitor = new RejectionMonitor();
| Outil | Plateforme | Cas d'usage | Overhead |
|---|---|---|---|
| DevTools Debugger | Navigateur + Node | Step-through debugging | Très haut (pause exécution) |
| Performance tab | Navigateur | Profiling global | Bas |
| User Timing API | Navigateur + Node | Mesurages précis custom | Très bas |
| Chrome inspector | Node.js | Debugging avec IDE | Modéré |
| Console logging | Partout | Debugging basique | Variable selon volume |
| Sentry/DataDog | Production | Monitoring erreurs | Bas (async) |
Astuce : Combinez console.group() avec console.table() pour une visualisation claire des données asynchrones complexes. Pour les profiles, utilisez la fonction performance.now() qui retourne une timestamp en millisecondes avec sous-précision, bien plus précise que Date.now().
Attention : Les DevTools ralentissent considérablement l'exécution quand le debugging est actif. Ne pas tirer de conclusions sur les performances en temps réel en mode debugging. Utiliser toujours un profiling distinct avec des outils comme clinic.js pour Node.js ou la Performance API pour le navigateur. Aussi, certaines optimisations du moteur JavaScript sont désactivées quand les DevTools sont connectées.