Back

Conserver le contexte à travers les appels asynchrones en Node.js

Conserver le contexte à travers les appels asynchrones en Node.js

Vous êtes à trois appels asynchrones de profondeur dans le traitement d’une requête HTTP. Vous avez besoin de l’ID de requête pour votre logger, de l’ID utilisateur pour votre requête de base de données, et de l’ID de locataire pour votre clé de cache. Faut-il les passer à travers chaque signature de fonction ? Cela devient vite désordonné.

Node.js propose une solution propre intégrée nativement : AsyncLocalStorage.

Points clés à retenir

  • AsyncLocalStorage de node:async_hooks propage le contexte à travers les frontières asynchrones sans polluer les signatures de fonctions.
  • Stable depuis Node.js 16.4.0, il est préférable à cls-hooked ou à l’utilisation directe de l’API bas niveau async_hooks.
  • Établissez le contexte une seule fois au point d’entrée de la requête avec run(), puis lisez-le n’importe où grâce à getStore().
  • Idéal pour les IDs de requête, les données de traçage, les métadonnées de locataire et le contexte d’authentification — pas pour l’état de la logique métier.
  • Attention à la perte de contexte avec des promesses non natives ou des APIs legacy basées sur des callbacks, ce que util.promisify() résout généralement.

Le problème de la propagation du contexte asynchrone

En code synchrone, vous pouvez utiliser une simple pile globale pour suivre le contexte. Mais les fonctions asynchrones cassent ce modèle. Lorsqu’un setTimeout se déclenche ou qu’une Promise se résout, la pile d’appels originale a disparu. Une simple variable globale serait partagée entre toutes les requêtes concurrentes — un bug sérieux en puissance dans tout serveur d’API réel.

Avant que AsyncLocalStorage ne devienne stable, les développeurs se tournaient vers des bibliothèques comme cls-hooked ou des solutions maison utilisant le module bas niveau async_hooks. Ces deux approches sont fragiles. L’API brute async_hooks est intentionnellement bas niveau et entraîne une surcharge de performance réelle lorsqu’elle est mal utilisée. Vous ne devriez pas construire votre code applicatif directement dessus.

AsyncLocalStorage, qui fait partie de node:async_hooks, est l’API haut niveau recommandée. Elle est stable depuis Node.js 16.4.0 et c’est ce que des frameworks comme AdonisJS utilisent en interne pour gérer le contexte HTTP.

Fonctionnement d’AsyncLocalStorage

AsyncLocalStorage fonctionne comme le stockage thread-local d’autres langages — à ceci près que Node.js est mono-thread, donc le « thread » est remplacé par un contexte d’exécution asynchrone. Toute opération asynchrone démarrée à l’intérieur d’un appel run() hérite automatiquement de ce contexte, y compris setTimeout, les chaînes de Promise et les appels await.

import { AsyncLocalStorage } from 'node:async_hooks';

const requestContext = new AsyncLocalStorage();

Vous créez une instance (typiquement en tant que singleton au niveau du module), puis vous utilisez run() pour établir un contexte au point d’entrée de chaque requête.

Un exemple réaliste de logging avec portée par requête

Voici un middleware Express minimal qui attache un ID de requête à chaque ligne de log — sans rien passer via les arguments de fonction :

import express from 'express';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

const requestContext = new AsyncLocalStorage();

// Middleware: establish context for each request
function contextMiddleware(req, res, next) {
  const store = { requestId: randomUUID(), userId: req.headers['x-user-id'] };
  requestContext.run(store, next);
}

// Logger: reads context without any arguments
function log(message) {
  const ctx = requestContext.getStore();
  const prefix = ctx ? `[${ctx.requestId}]` : '[no-context]';
  console.log(`${prefix} ${message}`);
}

// Simulated async database query
async function someDbQuery() {
  return new Promise((resolve) => setTimeout(resolve, 50));
}

// Route handler: calls async functions freely
async function fetchUserData() {
  log('Fetching user data');         // ✅ has request ID
  await someDbQuery();
  log('Fetched user data');          // ✅ still has request ID
}

const app = express();
app.use(contextMiddleware);

app.get('/user', async (req, res) => {
  log('Request received');
  await fetchUserData();
  res.json({ ok: true });
});

app.listen(3000);

L’idée clé : fetchUserData ne reçoit jamais l’ID de requête en paramètre. Le contexte se propage automatiquement à travers la frontière asynchrone parce qu’il a été établi avec run().

Que stocker dans le contexte

AsyncLocalStorage fonctionne bien pour les préoccupations transversales qui ont une portée par requête mais ne font pas partie de votre logique métier :

  • Les IDs de requête pour le traçage distribué et la corrélation des logs
  • Les métadonnées d’utilisateur authentifié ou de locataire pour les applications multi-tenant
  • Le contexte de trace pour des outils comme OpenTelemetry
  • Les feature flags résolus au moment de la requête

Évitez de stocker de gros objets ou tout ce qui change fréquemment. Gardez le store petit et traitez-le comme principalement en lecture après initialisation.

Un piège : la perte de contexte

Le contexte peut être perdu lors de l’utilisation d’implémentations de promesses non natives ou de certaines anciennes APIs basées sur des callbacks. Si getStore() renvoie undefined là où vous ne vous y attendez pas, vérifiez si l’opération asynchrone a été lancée à l’intérieur d’un appel run(). Envelopper du code basé sur des callbacks avec util.promisify() aide souvent, bien que certaines ressources asynchrones personnalisées puissent nécessiter AsyncResource.

Conclusion

AsyncLocalStorage résout élégamment un vrai problème. Au lieu de faire transiter les métadonnées de requête à travers chaque appel de fonction, vous établissez le contexte une seule fois à la frontière de la requête et le lisez où vous en avez besoin. C’est le bon outil pour le logging, le traçage et le contexte d’authentification à portée de requête dans toute API Node.js ou application SSR.

FAQ

Il y a une légère surcharge car Node.js doit suivre les ressources asynchrones pour propager le store, mais pour les charges web typiques, ce coût est négligeable. Les performances se sont nettement améliorées dans les versions récentes de Node.js, et le compromis en vaut généralement la peine comparé au fait de faire transiter manuellement le contexte à travers chaque appel de fonction.

Oui. Vous pouvez créer des instances séparées pour différentes préoccupations comme le contexte de logging, le traçage et les données de locataire. Chaque instance maintient son propre store indépendant, elles n'interfèrent donc pas entre elles. Veillez simplement à conserver chaque instance comme un singleton au niveau du module afin que la même référence soit utilisée partout dans votre code.

Chaque worker thread possède son propre état AsyncLocalStorage isolé, le contexte ne traverse donc pas les frontières des threads. Si vous devez partager le contexte de requête avec un worker, transmettez les données pertinentes explicitement via le canal de messages du worker et rétablissez le store à l'intérieur du worker avec un autre appel à run().

Le passage explicite est plus prévisible et plus facile à tester, mais il encombre les signatures de fonctions et pollue les couches intermédiaires qui n'ont pas réellement besoin des données. AsyncLocalStorage est idéal pour les préoccupations transversales comme le logging et le traçage, tandis que les données critiques pour la logique métier devraient toujours circuler via les arguments pour garder un code clair et testable.

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay