V8 GC et gestion mémoire — du heap layout au debugging d'OOM
TL;DR — V8 gère la mémoire via un garbage collector générationnel : les objets jeunes vivent dans le new space (Scavenger, GC très rapide, copie semi-space), promus vers l'old space s'ils survivent à plusieurs cycles (Mark-Sweep-Compact, plus coûteux). Depuis V8 7.0, le projet Orinoco parallelise et incrémentalise le GC ; Concurrent Marking réduit les pauses du main thread à quelques ms. Le heap est partitionné en spaces (new, old, code, large object, map), chacun avec sa stratégie. Les flags clés :
--max-old-space-size=4096(limite de l'old space en MB ; depuis Node 14 le défaut n'est pas un 4096 figé mais une valeur calculée à partir de la RAM physique — typiquement ~½ de la RAM, plafonnée vers 4 Go par défaut sur la plupart des configs),--max-semi-space-size=128(taille d'un semi-space en MB, défaut de l'ordre de 16-64 selon la version/arch). Les leaks classiques en Node sont les closures retenues (callback gardé par un eventemitter), les listeners non détachés (addListenersansremoveListener), les caches non bornés (Map qui ne purge jamais), et le global state qui retient des objets requests.WeakMap,WeakRefetFinalizationRegistrypermettent des références faibles utiles pour les caches et les ressources externes. Le debugging d'un OOM passe par les heap snapshots (DevTools, three-snapshot diff),--heap-prof(sampling continu), et l'analyse des dominateurs. La discipline : observer la courbe heap dans la durée, alerter sur la pente, et corriger les listeners et caches avant tout.
🧠 Mental model — ASCII + analogie
L'analogie classique : V8 gère sa mémoire comme une bibliothèque avec deux rayonnages. Les nouveaux livres arrivent dans le rayonnage "fraîcheur" (new space, petit, près de la porte). À chaque ménage rapide, on déplace les livres consultés vers l'autre moitié du rayonnage fraîcheur. Au bout de deux cycles, les livres qui survivent ("ceux qu'on consulte vraiment") sont transférés au rayonnage "archive" (old space, grand, profond). Le ménage du rayonnage archive est rare mais coûteux : on doit relire tous les rayonnages liés.
┌──────────────────────── V8 heap layout ────────────────────────┐
│ │
│ ┌─── New space (young generation) ────────────────────┐ │
│ │ ┌── to-space ──┐ ┌── from-space ──┐ (semi-spaces) │ │
│ │ │ alloc here │ │ empty/scrap │ │ │
│ │ └──────────────┘ └────────────────┘ │ │
│ │ Scavenger (Cheney's algo) — very fast, <1 ms │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─── Old space (old generation) ──────────────────────┐ │
│ │ Mark-Sweep-Compact, plus coûteux │ │
│ │ Concurrent Marking (depuis V8 7.0) → pauses ms │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌── Large object space ──┐ (objets > ~½ page, hors flow) │
│ ┌── Code space ──────────┐ (code JIT compilé) │
│ ┌── Old pointer/data ────┐ (hidden classes / Maps : ex- │
│ │ │ map_space, fusionné dans old) │
└────────────────────────────────────────────────────────────────┘Note de version — Le
map_spacehistorique (qui stockait les hidden classes /Mapinternes de V8, à ne pas confondre avec leMapJS) a été fusionné dans l'old space à partir de V8 9.4 (≈ Node 17/18). Sur du Node 18+,v8.getHeapSpaceStatistics()ne renvoie donc plus demap_space. De même, le seuil du large object space n'est pas « 1 MB » : V8 y range tout objet trop grand pour le new space (de l'ordre de quelques centaines de Ko selon la page size), alloué directement sans passer par les semi-spaces.
Le GC générationnel repose sur l'hypothèse empirique : la plupart des objets meurent jeunes (variables locales, objets intermédiaires de calcul). On optimise pour ce cas en gardant le new space petit et en le scavengeant souvent. Quand un objet "fait carrière", on le promet en old space où le GC est moins fréquent mais plus complet.
Orinoco (le projet V8 lancé en 2018) introduit le Concurrent Marking : le marquage des objets vivants se fait sur un thread séparé pendant que le main thread continue à exécuter du JS. La pause main thread n'est qu'à la finalisation (re-marquage des objets modifiés pendant le marquage concurrent). Résultat : pauses < 10 ms sur des heaps de plusieurs GB.
Pour aller plus en détail sur les algorithmes :
Scavenge (new space) — Algorithme de Cheney. Le new space est divisé en deux semi-spaces (
frometto). Tous les objets sont alloués dansto. Quandtoest plein, on lance un scavenge : on parcourt les roots (stack, globals), on suit les pointeurs, on copie les objets atteignables dansfrom. À la fin, on swap (fromdevientto, et l'ancientoest vidé d'un coup). Coût : proportionnel à la mémoire vivante, pas à la mémoire totale. C'est pourquoi c'est si rapide : la plupart des objets dans le new space meurent jeunes, donc peu sont à copier.Mark-Sweep-Compact (old space) — Trois phases. Marquage : on suit les pointeurs depuis les roots, on marque tous les objets atteignables. Sweep : on parcourt l'old space et on récupère les zones non marquées. Compact : on déplace les objets restants pour défragmenter. Coût : proportionnel à la taille totale du heap. C'est pourquoi c'est lent — on parle de centaines de ms sur des heaps de plusieurs GB.
Concurrent Marking — La phase de marquage s'exécute en parallèle de l'exécution JS. Pour gérer les modifications de pointeurs pendant le marquage, V8 utilise des write barriers : chaque écriture de pointeur dans un objet déjà marqué re-marque la cible. À la fin, une courte pause STW finalise.
Incremental Marking — Variante où le marquage est tronçonné en petits intervalles intercalés avec l'exécution JS. Réduit les pauses mais augmente le total CPU.
Parallel Compaction — Plusieurs threads coopèrent pour déplacer les objets pendant la phase compact. Réduit la durée de la pause.
V8 combine ces techniques selon la charge. En pratique, sur un heap de 1 GB sain, les pauses GC majeures sont rares et < 50 ms. Sur un heap fragmenté ou mal dimensionné, ça peut atteindre plusieurs secondes — d'où l'importance de surveiller les durées de pauses GC en prod via --trace-gc-verbose ou un agent comme Datadog/Sentry.
Sans concurrent marking: Avec concurrent marking:
main: ──────[MARK 200ms]────── main: ──[FINAL 5ms]──
GC: (rien) GC: ████████ (concurrent)Tableau de décision — quelle phase coûte quoi
| Mécanisme | Space | Déclencheur | Coût (ordre de grandeur) | STW ? | Ce qu'un staff surveille |
|---|---|---|---|---|---|
| Scavenge (minor GC) | new | semi-space to plein | proportionnel au vivant du new space, < 1-2 ms | oui mais minuscule | fréquence (trop = --max-semi-space-size trop petit ou allocation excessive) |
| Mark-Compact (major GC) | old | seuil d'occupation old / pression mémoire | proportionnel à la taille du heap, 10-500 ms | finalisation seule (le reste concurrent) | durée P99 des pauses, fréquence |
| Concurrent Marking | old | en amont d'un major GC | CPU sur thread GC dédié, masqué | non (write barriers) | coût CPU « invisible » qui mange un cœur |
| Concurrent/Parallel Sweeping | old | après marquage | masqué/parallélisé | non | fragmentation résiduelle |
| Compaction | old | fragmentation détectée | déplacement d'objets, ajoute à la pause | oui (partie de la pause) | écart total_heap_size − used_heap_size |
Comment un staff raisonne. Le levier n°1 n'est presque jamais le tuning de flags — c'est réduire le travail du major GC en gardant l'old space petit (caches bornés, pas de rétention). Tuner --max-semi-space-size n'aide que si le --trace-gc montre une avalanche de Scavenge ; augmenter --max-old-space-size ne « répare » jamais un leak, ça ne fait que retarder l'OOM et allonger chaque pause majeure. Règle mentale : un leak se corrige dans le code, un workload allocatif sain se tune avec les flags.
🛠️ Code minimal (ts/js)
// memory-usage.ts — snapshot continu
import { memoryUsage } from 'node:process';
function pretty(bytes: number) {
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
setInterval(() => {
const m = memoryUsage();
console.log({
rss: pretty(m.rss), // Resident Set Size (total mémoire OS)
heapUsed: pretty(m.heapUsed),// JS heap utilisé
heapTotal: pretty(m.heapTotal),// JS heap alloué
external: pretty(m.external),// mémoire C++ liée (buffers, addons)
arrayBuffers: pretty(m.arrayBuffers),// ArrayBuffer (subset external)
});
}, 10_000);// weak-refs.ts — caches avec références faibles
const cache = new WeakMap<object, ComputedResult>();
function compute(key: { id: string }) {
const cached = cache.get(key);
if (cached) return cached;
const result = expensiveCompute(key);
cache.set(key, result);
return result;
}
// Quand `key` n'est plus référencé ailleurs, l'entrée du cache devient
// éligible au GC automatiquement — pas de fuite.// weakref-finalizer.ts — FinalizationRegistry pour libérer une ressource externe
class Connection {
constructor(public socket: any) {}
close() {
this.socket.destroy();
}
}
// IMPORTANT : la held value (2e arg) ne doit JAMAIS être le target lui-même —
// elle est retenue fortement par le registry et empêcherait la collecte (le
// callback ne se déclencherait alors jamais). On tient la ressource externe.
const registry = new FinalizationRegistry<{ socket: any }>(({ socket }) => {
console.warn('Connection collected without explicit close — leaking?');
socket.destroy(); // libérer la ressource native, pas via le target collecté
});
const conn = new Connection(socket);
// target = conn, heldValue = { socket } (≠ conn), unregisterToken = conn
registry.register(conn, { socket: conn.socket }, conn);
// Si conn devient unreachable, le callback est appelé (best effort, pas garanti).# Heap snapshot via signal — Node 20+
# Lancer le process et l'envoyer SIGUSR2 pour générer un snapshot
node --heapsnapshot-signal=SIGUSR2 server.js
kill -SIGUSR2 <pid>
# Fichier .heapsnapshot écrit dans cwd, chargeable dans Chrome DevTools
# Heap profile sampling — coût très faible, OK en prod
node --heap-prof --heap-prof-interval=512000 --heap-prof-dir=./profiles server.js
# Limiter le heap à 512 MB (utile en container)
node --max-old-space-size=512 server.js
# Ajuster le new space pour des workloads très allocatifs
node --max-semi-space-size=64 server.js
# Tracer le GC (logging verbeux)
node --trace-gc server.js
# Sortie : [pid] ts ms: scavenge X.X (Y.Y) -> Z.Z MB, ...
# GC en mode debug — exposer global.gc() pour forcer un GC dans les tests
node --expose-gc tests/leak.test.js// leak-test.ts — détecter un leak via force GC
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('no leak after 1000 iterations', () => {
global.gc!();
const before = process.memoryUsage().heapUsed;
for (let i = 0; i < 1000; i++) {
suspectFunction({ data: 'x'.repeat(1000) });
}
global.gc!();
const after = process.memoryUsage().heapUsed;
const diff = (after - before) / 1024 / 1024;
assert.ok(diff < 5, `heap grew by ${diff.toFixed(1)} MB`);
});
// Lancer avec: node --expose-gc --test// listener-leak.ts — exemple typique de leak
import { EventEmitter } from 'node:events';
const bus = new EventEmitter();
bus.setMaxListeners(0); // BAD: silence le warning mais ne corrige rien
function badPattern(req: Request) {
bus.on('event', () => process(req)); // listener attaché à chaque requête
// jamais détaché — bus retient req, donc le leak grandit linéairement
}
function goodPattern(req: Request) {
const handler = () => process(req);
bus.on('event', handler);
req.on('close', () => bus.off('event', handler)); // cleanup
}// lru-cache.ts — cache borné avec lru-cache
import { LRUCache } from 'lru-cache';
const cache = new LRUCache<string, ComputedResult>({
max: 1000, // maximum 1000 entrées (borne dure sur le COUNT)
maxSize: 100 * 1024 * 1024, // max 100 MB total (borne dure sur la TAILLE)
sizeCalculation: (value) => value.byteLength, // requis dès que maxSize est fixé
ttl: 1000 * 60 * 60, // expiration 1 heure (et non `maxAge`, déprécié)
ttlAutopurge: false, // ne purge PAS via un timer : éviction paresseuse à l'accès
// Callback appelé quand une entrée est évincée (eviction, ttl, set explicite)
dispose: (value, key) => {
if (value.fd) closeSync(value.fd); // libérer la ressource native
},
});
function getOrCompute(key: string): ComputedResult {
const cached = cache.get(key); // get() rafraîchit l'âge si updateAgeOnGet
if (cached) return cached;
const value = expensiveCompute(key);
cache.set(key, value);
return value;
}
// Métriques exposées pour Prometheus — attention au sens exact des champs
console.log({
count: cache.size, // nombre d'entrées
bytes: cache.calculatedSize, // somme des sizeCalculation (PAS un hit-rate !)
});
// lru-cache n'expose pas de hit/miss natif : on instrumente soi-même
// (compteur incrémenté dans getOrCompute selon cached truthy/falsy).// programmatic-heap-snapshot.ts — snapshot programmatique
import v8 from 'node:v8';
import { createWriteStream } from 'node:fs';
function captureHeapSnapshot() {
const filename = `/tmp/heap-${process.pid}-${Date.now()}.heapsnapshot`;
const stream = createWriteStream(filename);
const heapStream = v8.getHeapSnapshot();
heapStream.pipe(stream);
return new Promise<string>((resolve, reject) => {
stream.on('finish', () => resolve(filename));
stream.on('error', reject);
});
}
// Exemple : snapshot automatique si le heap dépasse un seuil
setInterval(async () => {
const heapUsedMB = process.memoryUsage().heapUsed / 1024 / 1024;
if (heapUsedMB > 1000) {
const file = await captureHeapSnapshot();
console.warn(`heap above 1GB, snapshot saved: ${file}`);
}
}, 60_000);// v8-stats.ts — observer les spaces du heap
import v8 from 'node:v8';
const stats = v8.getHeapStatistics();
console.log({
totalHeapSize: stats.total_heap_size,
usedHeapSize: stats.used_heap_size,
heapSizeLimit: stats.heap_size_limit, // = --max-old-space-size
mallocedMemory: stats.malloced_memory,
});
const spaces = v8.getHeapSpaceStatistics();
for (const space of spaces) {
console.log(`${space.space_name}: used=${space.space_used_size} avail=${space.space_available_size}`);
}
// Sortie type (Node 18+, plus de map_space — fusionné dans old_space) :
// new_space: used=12 MB avail=4 MB
// old_space: used=320 MB avail=180 MB
// code_space: used=8 MB avail=2 MB
// large_object_space: used=50 MB avail=0
// new_large_object_space: used=0 avail=0
// shared_space / shared_large_object_space: ... (multi-isolate, Node 20+)// finalization-registry.ts — pattern de cleanup external
class FileHandle {
private fd: number;
private closed = false;
constructor(fd: number) {
this.fd = fd;
FileHandle.registry.register(this, fd, this);
}
close() {
if (this.closed) return;
require('node:fs').closeSync(this.fd);
this.closed = true;
FileHandle.registry.unregister(this);
}
private static registry = new FinalizationRegistry<number>((fd) => {
console.warn(`FileHandle for fd=${fd} was GC'd without close — possible leak`);
try {
require('node:fs').closeSync(fd);
} catch {}
});
}🎯 Patterns courants
1. Surveiller la pente, pas l'instantané. Un heap à 500 MB n'est pas un leak si stable. Un heap qui grimpe 5 MB/h continuellement, c'est un leak. On expose heapUsed en métrique Prometheus, on alerte sur la dérivée.
2. Listeners avec cleanup systématique. Toute attachement de listener doit avoir un détachement. Patron type : EventEmitter.on(...) ; ... ; on('close', () => emitter.off(...)). Avec once, on est OK. Avec on, jamais sans off correspondant.
3. Caches bornés. Une Map qui grandit sans purge est un leak garanti. Utiliser une LRU (lru-cache npm) avec max et maxAge, ou un WeakMap quand la clé est un objet temporaire. Toujours mesurer la taille du cache.
4. WeakMap pour des associations objet → métadonnées. Quand on veut attacher de la donnée à un objet sans l'altérer ni le retenir : WeakMap. L'entrée disparaît automatiquement quand l'objet clé est collecté.
5. WeakRef pour des références "best effort". WeakRef permet de référencer un objet sans empêcher sa collecte. .deref() retourne l'objet ou undefined. Utile pour des caches L1 avec fallback. Attention : la collecte n'est pas déterministe.
6. FinalizationRegistry pour le cleanup externe. Quand un objet JS représente une ressource native (fd, socket, buffer alloué C++), on enregistre un callback de finalisation. Mais : le callback n'est pas garanti d'être appelé (à l'arrêt du process notamment). Toujours fournir un .close() explicite et utiliser le registry en filet de sécurité.
7. Ajuster --max-old-space-size au container. Si le container a 2 GB de RAM, mettre --max-old-space-size=1536 pour laisser de la marge pour le stack, le code, les libs C++. Sinon Node alloue jusqu'à 4 GB par défaut et OOM-kill par le kernel.
8. Borner les payloads en entrée. Un POST de 100 MB JSON alloue 100 MB + le parsing crée des objets intermédiaires. Imposer une limite (body-parser limit, ou middleware custom) évite qu'un seul client épuise le heap.
9. Streamer plutôt que bufferiser. Pour transformer un gros fichier ou un gros JSON, utiliser des streams (createReadStream, Transform, pipeline). On garde le heap petit même sur des giga-octets.
10. Object pooling pour les hot paths. Si une route crée 10 000 fois le même objet temporaire (un Buffer, un objet de calcul), on peut le pooler. Buffer.allocUnsafe + pool réutilise un slab. Attention : c'est une optimisation pointue, ne le faire que si profilé.
11. Éviter le global state. Un cache, un compteur, un EventEmitter en module-level retient des objets pour la durée du process. Sauf vraie raison (singleton de config), passer par injection ou par module avec lifecycle clair.
12bis. Diff de constructeurs. Dans DevTools, après comparison, on filtre par "Constructor" pour ne voir que la classe suspecte. On clique sur un objet, on regarde le retainer path — la chaîne de références qui retient l'objet. Souvent ça pointe vers une closure ou un listener spécifique, ce qui localise le bug à corriger.
13. Sizeof réel via internal. La vraie taille d'un objet en mémoire dépend de V8 (hidden classes, alignment, optimisations). object.byteLength n'existe que pour ArrayBuffer. Pour des objets génériques, V8 expose getHeapCodeStatistics() (taille du code JIT), mais pas un sizeof par instance. Pour estimer : compter les propriétés × 24 bytes (overhead V8) + taille des valeurs.
14. Pinning d'objets vivants pour debug. En debug, on peut volontairement garder une référence à un objet pour empêcher sa collecte et l'observer. Par exemple, dans un repl Node, const ref = global.someObject pin l'objet jusqu'à ce qu'on libère ref.
15. Heap snapshot diff workflow. En cas de leak suspect :
- Lancer le process sous charge faible, prendre snapshot A.
- Lancer la charge suspecte (route, batch).
- Prendre snapshot B.
- Refaire la même charge.
- Snapshot C.
- Dans DevTools : Comparison view, B vs A puis C vs B. Les objets qui croissent dans les deux deltas sont les leakers. Identifier les dominateurs (qui retient quoi).
🔄 Versions — Node 18 / 20 / 22 / 24
Node 18. Défauts heap : --max-old-space-size dérivé de la RAM (≈ moitié de la RAM physique, et non un 4096 figé — le confondre fait sur-allouer en container), --max-semi-space-size de l'ordre de 16 MB. V8 10.x avec Orinoco mature, map_space déjà fusionné dans l'old space. process.memoryUsage.rss() séparée pour mesurer juste le RSS (moins coûteuse que memoryUsage() complet, qui interroge V8).
Node 20. V8 11.x. Améliorations sur la finalisation incrémentale du GC. --heapsnapshot-signal stable pour générer un snapshot par signal POSIX. node --inspect avec heap snapshots qui se chargent plus vite côté DevTools.
Node 22. V8 12.x. Maglev (nouveau compilateur intermédiaire entre Sparkplug et TurboFan) qui réduit les allocations sur les boucles chaudes. Améliorations sur la compaction concurrente. Le défaut de --max-old-space-size s'adapte mieux au container RAM via /sys/fs/cgroup (rejoint un mode "auto" expérimental).
Node 24. V8 13.x. Sandbox V8 (mémoire isolée pour les objets V8, sécurité accrue). Concurrent Sweeping plus mature. process.memoryUsage plus précise et moins coûteuse. node --report-on-fatalerror --diagnostic-report génère un rapport JSON complet à l'OOM, avec heap stats, GC stats, et stack du moment du crash. --experimental-detect-module n'impacte pas la mémoire mais améliore le démarrage.
Sur l'API : WeakRef et FinalizationRegistry sont stables depuis Node 14. L'API node:v8 expose v8.getHeapStatistics(), v8.getHeapSpaceStatistics() (par space), v8.writeHeapSnapshot() pour générer programmatiquement.
Détail historique pour situer l'évolution V8 :
- 2008 (V8 1.0) : GC stop-the-world classique, pauses très visibles (centaines de ms à secondes).
- 2012 : Introduction du GC générationnel (new/old spaces).
- 2015 : Incremental Marking.
- 2018 (Orinoco) : Concurrent Marking + Parallel Compaction.
- 2021 : Concurrent Sweeping.
- 2024 (V8 13.x) : Maglev compiler qui réduit les allocations.
Cette évolution explique pourquoi les techniques de tuning ont changé. Il y a 10 ans, on conseillait d'éviter les allocations à tout prix ; aujourd'hui, V8 gère bien les allocations modérées sans pauses visibles. Le focus est plutôt sur borner les caches et détacher les listeners que sur la micro-optim des objets temporaires.
⚠️ Pitfalls — 6-10
1. Listeners non détachés. Le leak Node le plus fréquent. emitter.on('data', handler) répété à chaque requête sans off. Le warning "MaxListenersExceededWarning" est un symptôme — ne pas le silencer avec setMaxListeners(0).
2. Closures qui retiennent un grand contexte. Une fonction stockée (callback, promesse pendante) retient son scope lexical. Si ce scope contient un gros objet (req.body de 10 MB), il reste en mémoire tant que la closure existe. Réduire le scope ou copier juste ce qu'il faut.
3. Caches non bornés. const cache = new Map() + cache.set(...) partout = leak garanti dès qu'il y a un nombre infini de clés. Toujours borner (LRU) ou utiliser WeakMap.
4. Global state qui accumule. Un tableau requests = [] qu'on push à chaque requête sans purge → leak parfait. Toujours définir une fenêtre (rolling window) ou un cap.
5. --max-old-space-size non aligné au container. En Kubernetes, si la limite mémoire du pod est 512 MB et que Node alloue jusqu'à 4 GB par défaut, on est OOM-killed sans warning JS. Toujours configurer Node sur 70-80 % de la limite container.
6. Buffer.alloc vs allocUnsafe. allocUnsafe ne zero-init pas → ultra rapide mais peut contenir des données résiduelles (risque sécurité si on expose le buffer). alloc zero-init. Choisir selon le besoin, documenter le choix.
7. Compter sur FinalizationRegistry pour fermer des fd. Le callback n'est pas garanti d'être appelé, surtout à l'arrêt du process. Toujours fournir un .close() explicite et l'appeler en finally.
8. --expose-gc en prod. Permet d'appeler global.gc(), mais c'est un GC majeur synchrone (STW) — n'utiliser qu'en tests. Ne jamais activer en prod.
9. Heap snapshot en prod sur process busy. Le snapshot fait un STW de plusieurs centaines de ms à plusieurs secondes selon la taille. À faire sur instance retirée du LB.
10. ArrayBuffer/external pas pris en compte. heapUsed ne couvre que le JS heap. Les Buffer et ArrayBuffer allouent en external. Un service streaming peut avoir un heapUsed bas et un RSS énorme. Toujours surveiller RSS aussi.
11. Heap snapshot lu sans contexte. Un snapshot brut affiche des milliers d'objets. Sans savoir ce qui est normal (baseline) et ce qui est anormal (delta), on perd des heures. Toujours prendre un baseline propre avant la charge suspecte.
12. Confondre RSS et heap. RSS = mémoire allouée par l'OS au process, inclut le heap JS, le code, les buffers natifs, les stacks de threads. Un service avec 16 workers a un RSS énorme même avec un petit heap. Quand on parle de "le service consomme X MB", il faut préciser RSS ou heap.
13. Memory fragmentation cachée. Après beaucoup d'allocations/désallocations, le heap peut être fragmenté : 200 MB alloués mais que 100 MB utilisables. V8 fait régulièrement de la compaction pour défragmenter, mais c'est coûteux. On le voit dans les stats total_heap_size - used_heap_size qui devrait être faible (< 30 % d'écart).
14. Lire l'API process.memoryUsage comme exhaustif. Elle ne montre pas tout : pas les mmap files, pas le shared memory, pas les threads natifs (workers ont leur propre process.memoryUsage isolé). Pour la vraie image RAM, utiliser pmap <pid> (Linux) ou Activity Monitor.
🧪 Testing — node --test, benchmarks
// tests/no-leak.test.ts — vérifier qu'une fonction ne leak pas
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('handler does not leak memory over 5000 invocations', async () => {
// Warm-up + premier GC pour stabiliser
for (let i = 0; i < 100; i++) await handler({ id: i });
global.gc!();
const before = process.memoryUsage().heapUsed;
for (let i = 0; i < 5000; i++) await handler({ id: i });
global.gc!();
const after = process.memoryUsage().heapUsed;
const growthMB = (after - before) / 1024 / 1024;
assert.ok(growthMB < 10, `heap grew by ${growthMB.toFixed(1)}MB`);
});
// node --expose-gc --test tests/// tests/lru-cache.test.ts — vérifier qu'un cache est borné
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { LRUCache } from 'lru-cache'; // export NOMMÉ depuis v9 — plus de default
test('lru cache stays bounded', () => {
const cache = new LRUCache<string, number>({ max: 100 });
for (let i = 0; i < 10_000; i++) cache.set(`key-${i}`, i);
assert.equal(cache.size, 100);
});# Benchmark allocations
node --trace-gc bench/alloc.js 2>&1 | grep 'Mark-sweep'
# Compter les GC majeurs sur N opérations
# Profiling heap continu
node --heap-prof --heap-prof-interval=100000 --heap-prof-dir=./hp server.js
# Charge avec autocannon, regarder l'évolution heap dans clinic doctor
npx clinic doctor -- node server.jsStratégie de test pour les caches/listeners : créer N entrées, forcer GC, vérifier que le heap revient ~à la baseline. Pour les eventEmitters : compter listenerCount après opérations critiques, asserter ≤ 1.
// tests/listener-count.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
test('handler attaches and detaches listeners cleanly', () => {
const bus = new EventEmitter();
for (let i = 0; i < 100; i++) {
const req = createMockReq();
attachHandler(bus, req);
req.emit('close');
}
assert.equal(bus.listenerCount('event'), 0);
});// tests/memory-baseline.test.ts — vérifier que le heap revient à la baseline
import { test } from 'node:test';
import assert from 'node:assert/strict';
async function measureSteady(): Promise<number> {
global.gc!();
global.gc!(); // Deux GC pour stabiliser
await new Promise((r) => setImmediate(r));
return process.memoryUsage().heapUsed;
}
test('repeated work returns to baseline', async () => {
const baseline = await measureSteady();
for (let i = 0; i < 100; i++) await doWork();
const after = await measureSteady();
const growthMB = (after - baseline) / 1024 / 1024;
assert.ok(growthMB < 5, `heap grew by ${growthMB.toFixed(2)}MB`);
});Pour les vrais leaks subtils (qui ne se voient qu'après 1000+ itérations), augmenter le N et tolérer une petite croissance (V8 ne réduit pas immédiatement le heapTotal). On peut aussi utiliser weak-napi (addon natif) pour observer la collecte d'objets spécifiques.
🎬 Cas d'usage concrets
Scénario 1 — OOM service ingestion cabinet juridique
Cabinet : service Node ingérant les PDFs scannés des dossiers, OOM régulier après ~4h en prod (heap atteint --max-old-space-size=2048, V8 kill). Sans heap dump, impossible de comprendre.
Mise en place : --heapsnapshot-near-heap-limit=3 (V8 dump 3 snapshots avant OOM), volume /var/heapdumps monté hors container. Au prochain incident, analyse via Chrome DevTools : le retainer principal est un Map<dossierId, Buffer> qui accumulait les PDFs lus pour "cache" sans bornage. Le service charge en moyenne 800 dossiers × 30 MB = 24 GB théorique, OOM bien avant.
Fix : remplacer le Map par un LRU borné à 50 entrées (lru-cache), passer en streaming pour les usages one-shot (pipeline read -> process -> close, jamais conserver le Buffer). RSS stable à 380 MB, plus d'OOM en 6 semaines. process.memoryUsage().rss exposé en /metrics avec alerte > 1.5 GB.
Scénario 2 — Leak SaaS RH long-running
SaaS RH, processus Node BullMQ worker qui tourne 24/7. Heap croît de 80 MB/jour, redémarrage forcé par crontab toutes les 48h — pansement, pas un fix.
Diagnostic : capture 3 heap snapshots espacés de 30 min via v8.writeHeapSnapshot() exposé sur signal SIGUSR2. Comparaison "Objects allocated between snapshots" dans DevTools : 12 000 objets EmployeeContext retenus entre snap1 et snap3, croissance linéaire. Retainer : un EventEmitter global salaryEngine.on('compute', handler) ajouté à chaque job mais jamais off().
Fix : salaryEngine.once(...) à la place, ou pattern AbortController avec salaryEngine.on('compute', handler, { signal }) pour cleanup auto. Heap stabilisé à 220 MB indéfiniment. Le pattern "listener jamais retiré" est désormais détecté par un test d'intégration qui vérifie emitter.listenerCount('compute') reste borné après 1000 jobs.
Scénario 3 — GC tuning e-commerce peak
E-commerce qui constate des pauses GC perceptibles pendant le Black Friday : P99 latence checkout passe de 150 ms à 850 ms toutes les ~20 s, parfaitement régulier. Pattern caractéristique d'un major GC qui rythme le service.
Mesure via --trace-gc-verbose (extraite proprement et envoyée à Loki) : Mark-Sweep durait 380 ms toutes les 18 s, sur un heap de 1.4 GB. Le heap était dominé par un cache de sessions auth (~900 MB de strings JWT + métadonnées).
Plusieurs leviers : (1) externaliser le cache vers Redis (heap divisé par 4), (2) --max-old-space-size=1024 pour forcer des collections plus fréquentes mais plus courtes, (3) --max-semi-space-size=64 pour accélérer les Scavenge sur les allocations courtes (request handlers). Résultat : pauses GC < 80 ms, plus de spike P99 visible.
🛠️ Exemple end-to-end
Service avec heap dump on-signal, métriques GC exposées, LRU cache borné, et test de non-leak intégré — pattern pour un service long-running production.
import { createServer } from "node:http";
import { writeHeapSnapshot } from "node:v8";
import { PerformanceObserver, constants as perfConstants } from "node:perf_hooks";
import { LRUCache } from "lru-cache";
// ---- GC metrics ----
const gcStats = { count: 0, totalMs: 0, lastMs: 0, lastKind: 0 };
const gcObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
gcStats.count++;
gcStats.totalMs += entry.duration;
gcStats.lastMs = entry.duration;
gcStats.lastKind = (entry as any).detail?.kind ?? 0;
}
});
gcObserver.observe({ entryTypes: ["gc"], buffered: false });
// ---- bounded cache: hard guarantee against runaway memory ----
const sessionCache = new LRUCache<string, { userId: string; permissions: string[] }>({
max: 10_000,
ttl: 1000 * 60 * 15,
updateAgeOnGet: true,
});
// ---- heap dump on SIGUSR2 ----
process.on("SIGUSR2", () => {
const path = `/tmp/heap-${Date.now()}.heapsnapshot`;
console.log(`[diag] writing heap snapshot to ${path}`);
writeHeapSnapshot(path);
});
// ---- expose metrics ----
const server = createServer(async (req, res) => {
if (req.url === "/metrics") {
const mem = process.memoryUsage();
res.setHeader("content-type", "text/plain");
return res.end(
[
`node_memory_rss_bytes ${mem.rss}`,
`node_memory_heap_used_bytes ${mem.heapUsed}`,
`node_memory_heap_total_bytes ${mem.heapTotal}`,
`node_memory_external_bytes ${mem.external}`,
`node_memory_arraybuffers_bytes ${mem.arrayBuffers}`,
`node_gc_count_total ${gcStats.count}`,
`node_gc_pause_total_ms ${gcStats.totalMs.toFixed(2)}`,
`node_gc_pause_last_ms ${gcStats.lastMs.toFixed(2)}`,
`app_session_cache_size ${sessionCache.size}`,
].join("\n")
);
}
if (req.url?.startsWith("/session/")) {
const token = req.url.slice(9);
let session = sessionCache.get(token);
if (!session) {
// simulate downstream lookup
session = { userId: `u-${token.slice(0, 4)}`, permissions: ["read"] };
sessionCache.set(token, session);
}
res.setHeader("content-type", "application/json");
return res.end(JSON.stringify(session));
}
res.statusCode = 404;
res.end();
});
server.listen(3000, () => console.log("listening on :3000"));
// graceful shutdown
for (const sig of ["SIGTERM", "SIGINT"] as const) {
process.on(sig, () => {
gcObserver.disconnect();
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5000).unref();
});
}Test de non-leak (boucle d'allocations + vérification que le cache reste borné) :
import test from "node:test";
import assert from "node:assert/strict";
test("session cache stays bounded under load", async () => {
const { LRUCache } = await import("lru-cache");
const cache = new LRUCache<string, { v: number }>({ max: 100 });
for (let i = 0; i < 100_000; i++) {
cache.set(`k-${i}`, { v: i });
}
assert.equal(cache.size, 100, "cache must not exceed max");
});Points clés : PerformanceObserver sur GC sans dépendance, heap dump sur SIGUSR2 (utilisable en prod sans redéploiement), LRUCache borné avec TTL et eviction garantie, process.memoryUsage() exposé à Prometheus, test d'invariant pour bloquer les régressions de bornage.
🔁 Quand utiliser / éviter
Utiliser WeakMap quand : clé = objet, on veut attacher de la donnée sans retenir.
Utiliser WeakRef quand : on veut un cache best-effort, ou tracer la vie d'un objet.
Utiliser FinalizationRegistry quand : filet de sécurité pour libérer une ressource externe — jamais comme mécanisme principal.
Utiliser --trace-gc quand : on suspecte des pauses GC, on veut voir la fréquence/durée des cycles.
Utiliser heap snapshot quand : on a identifié un leak suspect et on veut tracer le retentor (dominateur).
Éviter --expose-gc en prod : c'est un debug tool.
Éviter de forcer le GC depuis le code applicatif : global.gc() provoque une pause STW. V8 sait mieux que vous quand collecter.
Éviter object pooling sans profil préalable : ajoute de la complexité et des bugs (oubli de release). Réservé aux hot paths mesurés.
Éviter --max-old-space-size extrême : trop bas = GC permanent (thrashing). Trop haut = pauses GC longues. Le sweet spot dépend du workload, généralement 1-4 GB pour un service web typique.
Heuristique de tuning. Si on observe des GC majeurs très fréquents (toutes les secondes), c'est qu'on est près de la limite — augmenter --max-old-space-size ou réduire les allocations. Si on observe des pauses GC > 100 ms, c'est que le heap est trop grand pour le hardware ou trop fragmenté — réduire la taille du heap, ou activer plus de threads (UV_THREADPOOL_SIZE). Si on a un OOM kill par le kernel, c'est que --max-old-space-size est trop haut par rapport à la RAM container.
Pattern OOM debugging. Quand un service crash par OOM :
- Activer
--report-on-fatalerrorqui dump un rapport JSON au crash (heap stats, stack, env). - Activer
--heap-profpour avoir un profil continu sans coût. - Si possible, prendre un snapshot juste avant l'OOM (via signal).
- Analyser le snapshot : qui domine le heap ? quelle classe a explosé ?
- Identifier la source d'allocation (en croisant avec le heap profile).
- Fixer (cache borné, listeners détachés, scope réduit).
En SaaS, l'OOM est souvent lié à un tenant qui envoie un payload anormal (gros JSON, beaucoup de connexions WebSocket, etc.). Borner les inputs et tagger les requêtes par tenant aide à identifier rapidement.
🔗 Liens
- V8 blog — Orinoco (GC) — https://v8.dev/blog/trash-talk
- V8 blog — Concurrent marking — https://v8.dev/blog/concurrent-marking
- Node.js
v8API — https://nodejs.org/api/v8.html - Chrome DevTools — heap snapshots — https://developer.chrome.com/docs/devtools/memory-problems/
- WeakRef proposal (MDN) — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef
- FinalizationRegistry — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry
- lru-cache (npm) — https://github.com/isaacs/node-lru-cache
- Article : "Memory leaks in Node.js" — https://blog.appsignal.com/2021/11/03/the-ultimate-guide-to-finding-memory-leaks-in-node.html
Pattern : monitoring des durées de GC. Avec perf_hooks.PerformanceObserver sur entryTypes: ['gc'], on observe chaque cycle GC : type (scavenge, markSweepCompact), durée. On expose la distribution en Prometheus, on alerte sur des pauses > 100 ms.
import { PerformanceObserver } from 'node:perf_hooks';
import { Gauge } from 'prom-client';
const gcDuration = new Gauge({
name: 'gc_duration_ms',
help: 'Last GC duration',
labelNames: ['kind'],
});
const obs = new PerformanceObserver((items) => {
for (const entry of items.getEntries()) {
const kind = entry.detail?.kind ?? 'unknown';
gcDuration.labels(String(kind)).set(entry.duration);
}
});
obs.observe({ entryTypes: ['gc'] });Pattern : pre-allocation pour les hot paths. Sur une fonction appelée 10 000 fois/s, créer un objet temporaire à chaque appel = 10 000 allocations/s à GC. Pré-allouer un buffer/objet et le réutiliser (avec reset entre usages) divise les allocations par 100×.
Pattern : streams over buffers. Lire un fichier de 1 GB en fs.readFile charge tout en heap. En streams, on traite chunk par chunk avec un heap qui reste petit. La règle : au-delà de 10 MB, on streame.
// streaming vs bufferisé
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
// BAD: tout en mémoire
const data = await readFile('huge.log');
const compressed = await gzip(data);
await writeFile('huge.log.gz', compressed);
// GOOD: streaming, heap minuscule
await pipeline(
createReadStream('huge.log'),
createGzip(),
createWriteStream('huge.log.gz')
);Pattern : éviter le boxing JS-natif. Passer des Buffer entre worker_threads avec transfer évite la copie. Pour des données numériques, utiliser des TypedArray (Int32Array, Float64Array) plutôt que Array de Number — divise par 8× la mémoire et accélère drastiquement les opérations vectorielles.
🏋️ Exercices
Progression : instrumenter → diagnostiquer un vrai leak → durcir en prod → casser puis réparer. Tout se lance en local avec node --test (Node 20+), aucun service externe requis.
Exercice 1 — Le détecteur de pente (implémenter)
Objectif : écrire un petit moniteur qui, à partir de N échantillons heapUsed espacés, décide leak / stable via une régression linéaire (pente en MB/h), pas via un seuil instantané.
Indice/Solution : garder un buffer circulaire des { t, heapUsed }, calculer la pente par moindres carrés (slope = cov(t, y) / var(t)). Appeler global.gc() (lancé avec --expose-gc) avant chaque mesure pour retirer le bruit du new space, sinon la pente est dominée par des objets transitoires non encore collectés. Seuil de décision : pente > ~2 MB/h et R² > 0.8 (sinon c'est du bruit).
Exercice 2 — Chasse au retainer (diagnostiquer)
Objectif : on te donne un module qui leak (un EventEmitter global + un cache Map non borné mélangés). Trouver lequel des deux leak et le prouver par un three-snapshot diff, sans lire le code source.
Indice/Solution : node --expose-gc puis v8.writeHeapSnapshot() à 3 instants (baseline / après charge / après 2e charge). Dans DevTools → Comparison, trier par « # Delta » : la classe qui croît dans les deux deltas est la coupable. Suivre le Retainer path : un chemin via (closure) → EventEmitter pointe vers les listeners, un chemin via Map → (string) pointe vers le cache. Écrire ensuite un test listenerCount(...) === 0 qui échoue tant que le bug est là.
Exercice 3 — Cache production-grade (durcir)
Objectif : transformer un Map de sessions en cache borné qui garantit à la fois un plafond d'entrées et un plafond mémoire, libère les ressources natives à l'éviction, et expose hits/misses en métriques.
Indice/Solution : LRUCache avec max et maxSize + sizeCalculation (les deux bornes sont indépendantes — maxSize seul ne plafonne pas le count si les objets sont minuscules). dispose pour fermer les fd. Instrumenter le hit/miss soi-même (lru-cache n'a pas de compteur natif). Test d'invariant : for 100_000 set → assert size <= max et assert calculatedSize <= maxSize.
Exercice 4 — Tenir un budget mémoire sous charge (production-grade)
Objectif : un endpoint qui transforme un JSON arbitrairement grand doit refuser au-delà de 5 MB de payload et streamer en dessous, sans jamais faire monter le heapUsed au-delà de +20 MB pendant 1000 requêtes concurrentes.
Indice/Solution : compter les bytes au fil des chunks (req.on('data')), destroy() la requête avec 413 dès le dépassement — ne jamais bufferiser puis vérifier (le mal est déjà fait). Pour le parsing en dessous du seuil, préférer un parser streaming (ex. stream-json) à JSON.parse sur le buffer entier. Vérifier avec autocannon + courbe heapUsed.
Exercice 5 — Casser puis réparer : la pause GC qui rythme (break-then-fix)
Objectif : reproduire volontairement un service dont la P99 spike toutes les ~20 s (major GC sur un gros old space), le mesurer, puis le réparer en faisant tomber la durée de pause sous 50 ms.
Indice/Solution : pour casser — un cache module-level qui accumule ~1 GB de strings + --max-old-space-size=1400. Observer avec --trace-gc : Mark-Compact ... 300+ ms périodique, corrélé aux spikes (instrumenter via PerformanceObserver entryTypes:['gc']). Pour réparer : (1) externaliser/borner le cache (heap ÷ 4 → marquage plus court), (2) éventuellement baisser --max-old-space-size pour des majors plus fréquents mais plus courts, (3) vérifier que total_heap_size − used_heap_size reste bas (pas de fragmentation). Mesurer la P99 avant/après pour prouver le gain.
Exercice 6 — FinalizationRegistry n'est pas un destructeur (break-then-fix)
Objectif : montrer expérimentalement que le callback d'un FinalizationRegistry n'est pas garanti d'être appelé (notamment à l'arrêt du process), puis concevoir le pattern correct.
Indice/Solution : enregistrer 1000 objets, en libérer les références, process.exit(0) immédiat → aucun callback ne s'exécute (le fd fuite à l'échelle OS). Le fix n'est pas « attendre le GC » mais : close() explicite idempotent appelé dans un finally, using/Symbol.dispose (Node 22+, explicit resource management) pour garantir le cleanup déterministe, et le registry uniquement en filet de sécurité avec un console.warn qui signale un close() oublié. Bonus : observer que même sans exit, l'ordre et le timing des callbacks sont non déterministes — donc jamais de logique métier dedans.
🎤 En entretien
Q : Pourquoi le Scavenge (minor GC) est-il si rapide alors que le Mark-Compact est lent, sur le même process ? Parce que le coût du Scavenge est proportionnel aux objets vivants copiés (et l'hypothèse générationnelle dit qu'ils sont rares — la plupart meurent jeunes), tandis que le Mark-Compact parcourt tout l'old space, qu'il scale avec la taille du heap. C'est pour ça que garder l'old space petit (caches bornés) est le vrai levier de latence, pas le tuning de flags.
Q : heapUsed est stable mais le pod se fait OOM-kill par Kubernetes. Que se passe-t-il ? Le RSS, pas le heap JS, dépasse la limite. Les Buffer/ArrayBuffer et la mémoire des addons natifs vivent en external/hors du heap V8 ; un service streaming peut avoir un heapUsed minuscule et un RSS énorme. Et --max-old-space-size ne plafonne que l'old space — il ne protège pas du dépassement RSS. Réponse : surveiller le RSS, aligner --max-old-space-size sur ~70-80 % de la limite container, et borner les buffers.
Q : Quand préférer WeakMap, WeakRef, et FinalizationRegistry — et le piège de chacun ?WeakMap : attacher des métadonnées à un objet-clé sans le retenir (l'entrée disparaît avec la clé) — piège : non itérable, clés objet uniquement. WeakRef : cache best-effort où l'objet peut disparaître à tout moment — piège : .deref() non déterministe, ne jamais en dépendre pour la correction. FinalizationRegistry : filet de sécurité pour libérer une ressource externe — piège : callback non garanti (surtout à l'arrêt), donc toujours un close()/using explicite comme mécanisme principal.
Q : On observe des pauses GC de 400 ms toutes les 20 secondes en prod. Comment tu diagnostiques et qu'est-ce qui les cause généralement ? Diagnostic : PerformanceObserver sur entryTypes:['gc'] (ou --trace-gc) pour confirmer que ce sont des Mark-Compact périodiques, corrélés aux spikes de latence. Cause typique : un old space gros (souvent un cache ou du global state qui retient ~1 GB) qui rend chaque marquage long. Fix par ordre d'impact : réduire la rétention (heap plus petit = marquage plus court), puis éventuellement ajuster --max-old-space-size pour arbitrer fréquence vs durée, et vérifier la fragmentation (total_heap_size − used_heap_size).
Récapitulatif
Le GC V8 est générationnel et orienté pause minimale : Scavenge ultra-rapide en new space, Mark-Sweep-Compact en old space, avec Concurrent Marking (Orinoco) qui ramène les pauses main-thread à quelques ms. Le heap se décompose en spaces (new, old, large object, code, map), chacun avec sa stratégie. On configure via --max-old-space-size (à aligner sur la RAM container, typiquement 70-80 %) et --max-semi-space-size (à pousser pour des workloads allocatifs). Les leaks classiques sont quatre : listeners non détachés, closures retenant un gros contexte, caches non bornés, et global state qui accumule. La parade : LRU bornés, off/removeListener systématique, scopes réduits, et WeakMap/WeakRef/FinalizationRegistry pour les associations transitoires. Le debugging passe par les heap snapshots (three-snapshot diff), --heap-prof en prod, et la surveillance de la pente du heap (pas l'instantané). Et toujours, sur un OOM, ouvrir --diagnostic-report qui contient tout : heap stats, stack, GC stats, environnement. La mémoire en Node n'est pas magique, mais elle est mesurable et corrigible avec discipline.