Event Loop dans Node.js : fonctionnement et exemples pratiques

1. Vue d’ensemble

Event Loop est le mécanisme principal qui permet à Node.js de traiter des milliers de requêtes simultanément en utilisant un seul thread pour JavaScript. Beaucoup d’entre vous écrivent du code async/await, setTimeout et Promise au quotidien, mais ne savent pas exactement quel est l’ordre réel d’exécution.

Cet article explique la structure de l’Event Loop à travers des phases spécifiques, distingue les microtasks des macrotasks, puis l’illustre avec du code exécutable. Après la lecture, vous pourrez prédire avec précision l’ordre d’exécution des logs pour des codes asynchrones complexes et éviter les erreurs courantes comme le blocage de l’event loop.

2. Comment fonctionne Node.js en single-thread

Node.js exécute JavaScript sur un thread principal (main thread), mais les tâches d’I/O (lecture de fichiers, appels réseau, requêtes BD) sont déléguées à libuv — une bibliothèque C qui gère l’I/O asynchrone via un pool de threads ou des mécanismes kernel (epoll, kqueue, etc.).

┌───────────────────────────┐
│   JavaScript (single thread) │
└──────────────┬────────────┘
               │ appelle fs.readFile, setTimeout, net...
               ▼
┌───────────────────────────┐
│     libuv (C/C++)          │
│  - Thread pool             │
│  - Event loop               │
│  - OS async APIs            │
└───────────────────────────┘

Quand une tâche d’I/O est terminée, libuv place le callback dans la file d’attente correspondante. L’Event Loop vérifie continuellement ces files d’attente et exécute les callbacks à leur tour — c’est là qu’intervient la « boucle ».

Note : Votre code JavaScript s’exécute toujours sur un seul thread. Si vous écrivez du code synchrone lourd (par exemple une boucle de calcul important), il va bloquer tout l’event loop, obligeant toutes les autres requêtes à attendre.

3. Les phases de l’Event Loop

L’Event Loop ne fonctionne pas comme une simple boucle mais traverse 6 phases dans un ordre fixe à chaque itération (tick) :

   ┌───────────────────────┐
┌─>│        timers          │  setTimeout, setInterval
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │     pending callbacks   │  Callback d'I/O en retard
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │        idle, prepare    │
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │          poll           │  Événements d'I/O
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │          check          │  setImmediate
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
└──┤     close callbacks     │  socket.on('close')
   └─────────────────────────┘

3.1. Description brève de chaque phase

  • timers : exécute les callbacks de setTimeout/setInterval dont le délai a expiré.
  • pending callbacks : exécute les callbacks d’I/O en retard du cycle précédent.
  • poll : attend et traite les nouveaux événements d’I/O (fin de lecture de fichier, réception de réponse réseau, etc.). C’est la phase la plus gourmande en temps.
  • check : exécute les callbacks de setImmediate.
  • close callbacks : traite les événements de fermeture, par exemple socket.on('close', ...).

Chaque phase a sa propre file d’attente (queue). L’Event Loop traite tous (ou jusqu’à une limite) les callbacks dans la file de la phase actuelle avant de passer à la phase suivante.

4. La file des microtasks : Promise et process.nextTick

En plus des 6 phases macrotask ci-dessus, Node.js a également une microtask queue qui est traitée entre chaque phase — pas besoin d’attendre la fin d’une boucle complète.

Il y a deux types de microtasks :

  • process.nextTick() — une file d’attente dédiée qui est toujours traitée avant tous les autres microtasks.
  • Promise.then/catch/finally — la file de microtask standard (selon la spec ECMAScript).
console.log('1 - sync start');

setTimeout(() => console.log('2 - setTimeout'), 0);

Promise.resolve().then(() => console.log('3 - promise'));

process.nextTick(() => console.log('4 - nextTick'));

console.log('5 - sync end');

Explication :

  • Les lignes console.log synchrones s’exécutent immédiatement (1, 5).
  • setTimeout place le callback dans la file des timers — s’exécute à la phase suivante.
  • process.nextTick et Promise.then sont tous deux des microtasks, mais nextTick a une priorité plus élevée.
  • Après l’exécution du code synchrone, Node.js vide la file nextTick, puis vide la file de microtask Promise, avant d’entrer dans la phase timers.
1 - sync start
5 - sync end
4 - nextTick
3 - promise
2 - setTimeout

Note : Si vous appelez process.nextTick() de manière récursive continue (par exemple en appelant de nouveau nextTick dans un callback), l’event loop ne passera jamais à la phase suivante — c’est ce qu’on appelle la « famine d’I/O ».

5. setTimeout vs setImmediate : quel est le bon ordre ?

C’est la question la plus source de confusion. L’ordre entre setTimeout(fn, 0) et setImmediate(fn) n’est pas fixe s’ils sont appelés au niveau racine, mais est fixe s’ils sont appelés dans un callback d’I/O.

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});

Puisque ce code se trouve dans le callback de fs.readFile — c’est-à-dire dans la phase poll — quand poll se termine, l’Event Loop bascule toujours vers la phase check (setImmediate) avant que la nouvelle boucle ne revienne à la phase timers.

immediate
timeout

Inversement, si vous placez ces deux instructions au niveau racine (en dehors de tout callback d’I/O), l’ordre dépend des performances du processus de démarrage de Node.js à ce moment-là — vous ne devriez pas compter sur cet ordre dans du code de production.

6. Bloquer l’event loop : l’erreur la plus courante

Puisqu’il n’y a qu’un seul thread pour exécuter JavaScript, tout code synchrone qui s’exécute longtemps va bloquer tout le serveur.

const express = require('express');
const app = express();

app.get('/fibonacci', (req, res) => {
  function fib(n) {
    return n <= 1 ? n : fib(n - 1) + fib(n - 2);
  }
  res.send(`Résultat : ${fib(40)}`); // bloquant !
});

app.listen(3000);

Quand une requête appelle /fibonacci, la fonction fib(40) s’exécute de manière synchrone et prend plusieurs secondes. Pendant ce temps, toutes les autres requêtes (même un simple ping) doivent attendre, car l’event loop est bloquée sur une seule tâche.

6.1. Solution : déléguer le travail lourd aux Worker Threads

const { Worker } = require('worker_threads');

app.get('/fibonacci', (req, res) => {
  const worker = new Worker('./fib-worker.js', {
    workerData: { n: 40 }
  });

  worker.on('message', (result) => res.send(`Résultat : ${result}`));
  worker.on('error', (err) => res.status(500).send(err.message));
});
// fib-worker.js
const { workerData, parentPort } = require('worker_threads');

function fib(n) {
  return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}

parentPort.postMessage(fib(workerData.n));

Le Worker s’exécute sur son propre thread, sans occuper le main thread. L’event loop de la requête principale reste libre pour traiter les autres requêtes pendant que le worker effectue les calculs.

Quand vous n’avez PAS besoin de Worker Thread : pour les tâches d’I/O (lecture de BD, appels API, lectures de fichiers) — ce sont déjà des opérations asynchrones, pas besoin de worker. Les Worker Threads sont utiles uniquement pour les tâches CPU-bound (calculs intensifs, chiffrement, traitement d’images, etc.).

7. Mesurer le retard de l’event loop avec perf_hooks

Pour détecter si l’event loop est bloquée en environnement de production, vous pouvez utiliser le module perf_hooks intégré.

const { monitorEventLoopDelay } = require('perf_hooks');

const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();

setInterval(() => {
  console.log(`Retard de l'event loop (moyenne) : ${histogram.mean / 1e6} ms`);
  histogram.reset();
}, 5000);

monitorEventLoopDelay crée un histogramme qui mesure le temps que l’event loop « retarde » par rapport au calendrier idéal. Si la valeur mean augmente anormalement (par exemple plus de 100 ms), c’est un signe qu’un code bloque quelque part.

8. Écrire des tests pour vérifier l’ordre d’exécution

Pour confirmer votre compréhension de l’ordre microtask/macrotask, vous pouvez écrire un test qui enregistre les logs dans un tableau puis fait une assertion sur l’ordre.

// order.js
function recordOrder(log) {
  log.push('sync-start');

  setTimeout(() => log.push('timeout'), 0);

  Promise.resolve().then(() => log.push('promise'));

  process.nextTick(() => log.push('next-tick'));

  log.push('sync-end');
}

module.exports = { recordOrder };
// order.test.js
const { recordOrder } = require('./order');

test('ordre d\'exécution : sync -> nextTick -> promise -> timeout', (done) => {
  const log = [];
  recordOrder(log);

  // le code sync s'exécute immédiatement
  expect(log).toEqual(['sync-start', 'sync-end']);

  setImmediate(() => {
    expect(log).toEqual([
      'sync-start',
      'sync-end',
      'next-tick',
      'promise',
      'timeout',
    ]);
    done();
  });
});

Ce test utilise Jest avec setImmediate pour attendre que tous les microtasks et la phase des timers se terminent, puis il fait une assertion sur l’ordre final du log. C’est une façon de valider correctement votre compréhension de l’event loop au lieu de simplement lire les logs à l’écran.

npx jest order.test.js
PASS  ./order.test.js
  ✓ ordre d'exécution : sync -> nextTick -> promise -> timeout (5 ms)

9. Erreurs courantes

  • Utilisation excessive de process.nextTick de manière récursive : cause une famine d’I/O, le serveur ne peut pas traiter les nouvelles requêtes.
  • Penser que setTimeout(fn, 0) s’exécute immédiatement : en réalité, il attend toujours au moins un tick de l’event loop, et attend après tous les microtasks.
  • Exécuter du code CPU-bound sur le main thread : par exemple parser du JSON volumineux, chiffrer avec bcrypt à coût élevé, traiter des images de façon synchrone — tout cela devrait être délégué aux Worker Threads ou aux processus enfants.
  • Supposer l’ordre entre setTimeout et setImmediate au niveau racine : ce n’est pas fiable, évitez d’écrire de la logique qui en dépend.

Quand soyez vigilant : si votre application a des endpoints qui traitent des données volumineuses (export Excel, calculs statistiques, redimensionnement d’images), mesurez régulièrement monitorEventLoopDelay pour détecter rapidement avant que cela n’affecte tout le serveur.

10. Conclusion

Event Loop est la raison pour laquelle Node.js peut gérer un grand nombre de connexions avec peu de ressources, mais cela s’accompagne d’une contrainte : tout code JavaScript synchrone s’exécute sur un seul et même thread. Comprendre les 6 phases (timers, pending callbacks, poll, check, close) et la position de la microtask queue (process.nextTick, Promise) vous aide à prédire correctement l’ordre d’exécution et à éviter les erreurs difficiles à déboguer.

Quand vous rencontrez des tâches CPU-bound, n’hésitez pas à utiliser worker_threads ou à les déléguer à un processus séparé — l’event loop ne fonctionne bien que si vous ne l’étouffez pas.

nodejsevent-loopjavascriptasynchronousbackend