JavaScript Avancé

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.

Preparetoi.academy 30 min

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.

Accédez à des centaines d'examens QCM — Découvrir les offres Premium