Mémoire et performance JS/TS - 16 - Serveurs Node.js et mémoire

Gerer la mémoire d'un serveur Node.js en production : limites V8, pools de connexions, caches bornes, et monitoring.

  1. 01 Mémoire et performance JS/TS - 00 - Pourquoi la mémoire compte meme avec un garbage collector
  2. 02 Mémoire et performance JS/TS - 01 - Stack vs Heap
  3. 03 Mémoire et performance JS/TS - 02 - Le cycle de vie de la mémoire
  4. 04 Mémoire et performance JS/TS - 03 - Le garbage collector
  5. 05 Mémoire et performance JS/TS - 04 - V8 en profondeur
  6. 06 Mémoire et performance JS/TS - 05 - Les 6 fuites mémoire classiques
  7. 07 Mémoire et performance JS/TS - 06 - Closures et mémoire
  8. 08 Mémoire et performance JS/TS - 07 - WeakRef, WeakMap et WeakSet
  9. 09 Mémoire et performance JS/TS - 08 - FinalizationRegistry : savoir quand le GC passe
  10. 10 Mémoire et performance JS/TS - 09 - DevTools Memory : investiguer dans Chrome
  11. 11 Mémoire et performance JS/TS - 10 - Profiling mémoire en Node.js
  12. 12 Mémoire et performance JS/TS - 11 - Détecter et corriger les fuites mémoire
  13. 13 Mémoire et performance JS/TS - 12 - ArrayBuffer et TypedArrays
  14. 14 Mémoire et performance JS/TS - 13 - Workers et mémoire partagee
  15. 15 Mémoire et performance JS/TS - 14 - Streams et backpressure
  16. 16 Mémoire et performance JS/TS - 15 - Fuites mémoire en React
  17. 17 Mémoire et performance JS/TS - 16 - Serveurs Node.js et mémoire
  18. 18 Mémoire et performance JS/TS - 17 - Mémoire et Docker
  19. 19 Mémoire et performance JS/TS - 18 - Optimisations mémoire
  20. 20 Mémoire et performance JS/TS - 19 - Comparaison avec d'autres langages
  21. 21 Mémoire et performance JS/TS - 20 - Tester la mémoire
  22. 22 Mémoire et performance JS/TS - 21 - Glossaire

16 - Serveurs Node.js et mémoire

Ce que tu vas apprendre

  • Comment configurer --max-old-space-size et pourquoi la valeur par défaut ne suffit pas toujours
  • Les erreurs classiques qui font exploser la mémoire d'un serveur
  • Dimensionner les pools de connexions sans gaspiller
  • Mettre en place un monitoring mémoire et un redemarrage gracieux

Prerequisites

  • Avoir une appli Node.js en production (ou en staging)
  • Avoir lu le profiling Node.js pour les outils de diagnostic

512 Mo par défaut, et apres ?

V8 alloue par défaut environ 1.5 Go pour le old space sur les systèmes 64 bits. Ca parait beaucoup, mais un serveur qui traite des fichiers, manipule du JSON volumineux ou fait du SSR peut atteindre cette limite en quelques heures.

bashnode --max-old-space-size=2048 server.js

Cette option fixe la taille maximale du old space en Mo. Quelques repères :

  • API REST legere : 256-512 Mo suffisent
  • SSR React/Next.js : 1024-2048 Mo selon le trafic
  • Traitement de fichiers : 2048-4096 Mo si tu charges des fichiers en mémoire (mais tu devrais utiliser des streams)

Le piège : augmenter la limite masque les fuites. Si ta mémoire monte lineairement, 4 Go au lieu de 1.5 Go te donne juste plus de temps avant le crash. Ca ne fixe rien.

Ne stocke pas les donnees de requête globalement

typescript// La fuite classique du serveur
const requestCache = new Map<string, unknown>();

app.post("/webhook", (req, res) => {
  const id = req.body.id;
  requestCache.set(id, req.body); // On ajoute
  processWebhook(req.body);
  res.sendStatus(200);
  // On ne supprime jamais de requestCache
});

J'ai vu ce pattern sur trois projets différents. L'intention est bonne : deduplication ou idempotence. Mais sans nettoyage, le Map grossit indefiniment. En production avec 1000 requêtes par minute, ca fait 1.4 million d'entrees par jour.

typescript// Fix : TTL sur les entrees
const requestCache = new Map<string, { data: unknown; timestamp: number }>();

function cleanCache() {
  const now = Date.now();
  for (const [key, value] of requestCache) {
    if (now - value.timestamp > 5 * 60 * 1000) {
      requestCache.delete(key);
    }
  }
}
setInterval(cleanCache, 60_000);

Ou mieux, utilise un LRU cache avec une taille maximale.

Pools de connexions : le bon dimensionnement

typescriptimport { Pool } from "pg";

const pool = new Pool({
  max: 20, // 20 connexions max
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
});

Chaque connexion PostgreSQL consomme environ 5 a 10 Mo de RAM cote serveur de base de donnees. 20 connexions = 100 a 200 Mo. Si tu as 4 instances Node.js avec chacune un pool de 20, ca fait 80 connexions et potentiellement 800 Mo cote PostgreSQL.

PgBouncer resout ca en mutualisant les connexions. 80 connexions applicatives passent par 20 connexions reelles a la base. Pour Redis, c'est moins critique parce que Redis est single-threaded et les connexions sont legeres, mais un pool de 10-20 reste raisonnable.

Mon conseil : commence avec max: 10, mesure les temps d'attente de connexion, et augmente si nécessaire. Trop de connexions gaspille de la mémoire sans gain de performance.

Caches bornes, pas des Map infinies

typescriptimport { LRUCache } from "lru-cache";

const cache = new LRUCache<string, Buffer>({
  max: 500, // 500 entrees max
  maxSize: 50 * 1024 * 1024, // 50 Mo max au total
  sizeCalculation: (value) => value.byteLength,
  ttl: 1000 * 60 * 10, // 10 minutes
});

Un Map sans limite de taille, c'est une fuite mémoire qui attend de se produire. Un LRU cache evince les entrees les moins recemment utilisees quand il atteint sa taille maximale. Tu contrôles la mémoire.

Sur paltemps.fr, je cache les réponses de l'API meteo avec un LRU de 200 entrees et un TTL de 5 minutes. Les donnees meteo changent lentement, mais je ne veux pas que le cache grossisse indefiniment si quelqu'un scanne toutes les villes de France.

Event emitters : la limite de listeners

typescriptimport { EventEmitter } from "events";

const emitter = new EventEmitter();
emitter.setMaxListeners(20); // Par defaut : 10

// Le warning que tu ne dois pas ignorer :
// MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
// 11 'data' listeners added to [EventEmitter].

Ce warning existe pour une raison. Si tu ajoutes des listeners sans les retirer, chaque listener retient une closure et tout ce qu'elle référencé. Le fix : retire tes listeners quand tu n'en as plus besoin, ou utilise once() pour les listeners a usage unique.

process.memoryUsage() : ton premier monitoring

typescriptfunction logMemory() {
  const usage = process.memoryUsage();
  console.log({
    rss: `${Math.round(usage.rss / 1024 / 1024)} Mo`,
    heapUsed: `${Math.round(usage.heapUsed / 1024 / 1024)} Mo`,
    heapTotal: `${Math.round(usage.heapTotal / 1024 / 1024)} Mo`,
    external: `${Math.round(usage.external / 1024 / 1024)} Mo`,
  });
}

setInterval(logMemory, 30_000);
  • rss : la mémoire totale du process vue par l'OS
  • heapUsed : ce que V8 utilise vraiment dans le heap
  • heapTotal : ce que V8 a reserve (peut etre plus que heapUsed)
  • external : mémoire des objets C++ lies a des objets JS (Buffers par exemple)

Si heapUsed monte régulièrement sans jamais redescendre, tu as une fuite. Si rss est tres supérieur a heapTotal, tu as de la mémoire native (Buffers, addons C++) qui consomme.

Redemarrage gracieux sur seuil mémoire

typescriptconst MEMORY_THRESHOLD = 1.5 * 1024 * 1024 * 1024; // 1.5 Go

setInterval(() => {
  const { heapUsed } = process.memoryUsage();
  if (heapUsed > MEMORY_THRESHOLD) {
    console.log("Seuil memoire atteint, arret gracieux...");
    server.close(() => {
      process.exit(0); // Le process manager (PM2, systemd) relancera
    });
  }
}, 60_000);

Ce n'est pas une solution. C'est un filet de sécurité. Ca évité le OOM kill brutal (exit code 137) en arretant proprement : on finit les requêtes en cours, on ferme les connexions, et on laisse le process manager relancer une instance fraiche.

Le vrai fix, c'est de trouver et corriger la fuite. Mais en attendant, un redemarrage gracieux a 1.5 Go c'est mieux qu'un kill a 2 Go avec des requêtes perdues.

L'état global qui s'accumule

Un dernier pattern vicieux : les modules qui accumulent de l'état entre les requêtes.

typescript// middleware/logger.ts
const logs: string[] = []; // Persiste entre toutes les requetes

export function logRequest(req: Request) {
  logs.push(`${new Date().toISOString()} ${req.method} ${req.url}`);
  // logs n'est jamais vide
}

En Node.js, les modules sont des singletons. Ce tableau logs vit pour toute la duree du process. Chaque requête l'allonge. En production, ca peut representer des dizaines de millions d'entrees.

Résumé

  • --max-old-space-size definit la limite du heap V8, pas de la mémoire totale du process.
  • Les Map et tableaux globaux sans limite de taille sont des fuites en attente.
  • Les LRU caches avec max size et TTL controlent la mémoire.
  • process.memoryUsage() est le monitoring minimal a mettre en place.
  • Un redemarrage gracieux sur seuil protégé contre les OOM kills en production.

Precedent : Fuites mémoire en React Suivant : Mémoire et Docker

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.