Profiling Node.js — du DevTools aux flamegraphs et au continuous profiling en prod
TL;DR — Profiler Node, c'est répondre à trois questions : où le CPU passe-t-il son temps, combien la boucle d'événements lag, et comment évolue la mémoire. L'outillage local couvre l'essentiel :
node --inspectouvre un débogueur Chrome DevTools avec CPU profiling et heap snapshots ;clinic.js(doctor, flame, bubbleprof, heapprofiler) automatise le diagnostic ;0xgénère des flamegraphs à partir d'une traceperf;autocannonetwrkinjectent de la charge réaliste. Côté API,perf_hooks(Performance API,PerformanceObserver,monitorEventLoopDelay) instrumente le code sans dépendance. En production, le continuous profiling (Pyroscope, Sentry profiling, Datadog Continuous Profiler) capture des profils CPU/heap en permanence à coût négligeable (1-3 % CPU), avec anonymisation des chemins et des littéraux pour éviter de fuiter des PII. Les pièges classiques : profiler en mode dev (avec sourcemaps qui faussent), profiler une charge non représentative, lire un flamegraph sans regarder la baseline, et oublier que l'async stack peut tronquer les chaînes vues dans le profil. Le bon réflexe : mesurer la latence p99 et l'event loop lag d'abord, puis ouvrir un profil ciblé pour comprendre le top consommateur.
🧠 Mental model — ASCII + analogie
Pour profiler intelligemment, il faut d'abord savoir ce qu'on cherche. Trois questions distinctes ne demandent pas les mêmes outils :
"Pourquoi mon endpoint est lent ?" — On veut identifier où le temps se passe sur un chemin de requête. Outil : tracing distribué (OpenTelemetry, Sentry), pas profiling. Le tracing montre les spans (DB query 50 ms, HTTP downstream 200 ms, parsing 30 ms) tandis qu'un profil agrège tout sur le process.
"Pourquoi mon CPU est saturé ?" — On veut savoir quelle fonction consomme le CPU. Outil : CPU profile (clinic flame, 0x, Pyroscope).
"Pourquoi ma mémoire grimpe ?" — On veut savoir qui alloue et qui retient. Outil : heap profile + heap snapshot.
Mélanger ces questions mène à des heures de debug stérile. Si la latence d'un endpoint est haute mais le CPU bas, ce n'est pas un problème de profil — c'est un problème de tracing (où va le temps ?). Le profil dit qui consomme le CPU effectivement utilisé, pas où le temps wall-clock est perdu.
Un profil Node, c'est une radiographie temporelle du process. À chaque échantillon (toutes les ms typiquement), V8 enregistre la pile d'appels en cours. Au bout de N secondes, on agrège : les fonctions qui apparaissent le plus en haut de pile sont les self-time hot spots. Un flamegraph empile ces stacks de la racine vers le sommet, la largeur représente le temps cumulé.
┌───────────────── flamegraph ──────────────────┐
│ │
width = time spent │ ┌── parseJSON ──┐ │
│ ┌── handler ───┴───── db.query ──┐ │
│ │ │ │
on top = self time │ ├── middleware ──────────────────┤ │
│ │ │ │
│ └── eventLoop ──────────────────────────────┘│
└───────────────────────────────────────────────┘Mais le CPU ne raconte que la moitié de l'histoire en Node. La boucle d'événements peut être bloquée par un seul tick lent (parsing JSON géant, regex catastrophique, sync I/O). On mesure ça avec perf_hooks.monitorEventLoopDelay qui échantillonne le délai entre deux ticks. Un lag > 50 ms = signal d'alerte ; > 200 ms = utilisateurs qui souffrent.
Event loop activity over 1s:
tick 1: ▓ (~10 ms)
tick 2: ▓▓ (~25 ms)
tick 3: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ (~300 ms ← blocage CPU)
tick 4: ▓ (~10 ms)La mémoire suit une dynamique parallèle : process.memoryUsage() donne RSS, heap, external, arrayBuffers. Un heap qui grimpe sans redescendre = leak suspect. Un heap snapshot (--heap-prof ou DevTools) capture l'arbre des objets retenus et permet de tracer les dominateurs (objets qui retiennent à eux seuls une grande chaîne d'autres).
L'analogie médicale : perf_hooks est le pouls (continu, basse résolution) ; --inspect + DevTools est l'IRM (ponctuelle, haute résolution) ; le continuous profiling est l'examen sanguin de routine.
Il faut bien distinguer trois types de profils :
- CPU profile (sampling) — V8 échantillonne la pile d'appels à intervalle régulier (par défaut 1 ms). Donne une vision statistique du temps passé par fonction. Coût : faible si l'intervalle est raisonnable.
- Heap profile (sampling) — V8 échantillonne les allocations. Permet d'identifier qui alloue beaucoup (en cumul). Différent d'un heap snapshot qui capture l'état instantané du heap.
- Heap snapshot — capture instantanée de tous les objets vivants du heap. Énorme (peut faire plusieurs GB en sortie), coûteux à prendre (STW de plusieurs secondes), mais permet l'analyse fine des dominateurs et chemins de rétention.
Pour comprendre un service en prod, on combine les trois : continuous profiling pour la vue agrégée, heap snapshot ponctuel sur leak suspect, CPU profile ciblé sur une charge particulière.
L'eventLoopUtilization est une métrique sous-utilisée mais précieuse. Elle indique le ratio de temps que la boucle passe en activité vs idle. Une valeur de 0.95 signifie que la boucle est saturée à 95 % — pas de marge pour absorber un pic. À 0.50, il reste de la marge. Cette métrique est plus stable que le lag (qui dépend des pics) et plus actionable que le CPU global du process.
Sur le flamegraph, la lecture demande de l'entraînement. Les barres se lisent de bas en haut : la racine est en bas (typiquement la boucle d'événements ou un handler), les feuilles en haut. La largeur d'une barre représente le temps cumulé (samples). Une barre haute et fine = peu de samples = pas un hot spot. Une barre large = beaucoup de samples = candidat à l'optimisation. Mais attention : une barre large parent qui appelle 10 enfants étroits est moins intéressante qu'un sommet de pile (self time) large.
Les icicle graphs sont l'inverse visuel des flamegraphs (lecture de haut en bas), parfois préférés pour des arbres profonds. Pyroscope et Sentry permettent les deux. La couleur, souvent arbitraire, peut être configurée pour signifier autre chose (par exemple chaud/froid selon le delta avec la baseline).
🛠️ Code minimal (ts/js)
# 1. Démarrer Node en mode inspect
node --inspect server.js
# Ou pour break dès le démarrage
node --inspect-brk server.js
# Puis ouvrir chrome://inspect dans Chrome → "Open dedicated DevTools for Node"// perf-hooks.ts — mesurer une portion de code avec Performance API
import { performance, PerformanceObserver } from 'node:perf_hooks';
const obs = new PerformanceObserver((items) => {
for (const entry of items.getEntries()) {
console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
}
});
obs.observe({ entryTypes: ['measure'] });
performance.mark('parse-start');
const parsed = JSON.parse(bigPayload);
performance.mark('parse-end');
performance.measure('parse-json', 'parse-start', 'parse-end');// event-loop-lag.ts — monitorer le lag
import { monitorEventLoopDelay } from 'node:perf_hooks';
const histo = monitorEventLoopDelay({ resolution: 20 });
histo.enable();
setInterval(() => {
console.log({
p50: histo.percentile(50) / 1e6, // ns → ms
p99: histo.percentile(99) / 1e6,
max: histo.max / 1e6,
});
histo.reset();
}, 5000);// memory-usage.ts — snapshot léger de la mémoire
import { memoryUsage } from 'node:process';
setInterval(() => {
const m = memoryUsage();
console.log({
rss: Math.round(m.rss / 1e6) + ' MB',
heapUsed: Math.round(m.heapUsed / 1e6) + ' MB',
heapTotal: Math.round(m.heapTotal / 1e6) + ' MB',
external: Math.round(m.external / 1e6) + ' MB',
});
}, 10_000);# Clinic doctor — diagnostic complet avec recommandations
npx clinic doctor -- node server.js
# Lancer une charge en parallèle
npx autocannon -c 100 -d 30 http://localhost:3000
# Ctrl+C → ouvre un rapport HTML avec event loop, CPU, memory, handles
# Clinic flame — flamegraph CPU
npx clinic flame -- node server.js
# Clinic bubbleprof — analyse async (où traîne l'événementiel)
npx clinic bubbleprof -- node server.js
# Clinic heapprofiler — heap analysis
npx clinic heapprofiler -- node server.js# 0x — alternative au flamegraph clinic, basé sur perf_events
npx 0x server.js
# Sortie : un dossier 0x-* avec un flamegraph.html# CPU profile en CLI sans DevTools — V8 CPU profiler natif
node --cpu-prof --cpu-prof-dir=./profiles --cpu-prof-interval=200 server.js
# Crée un fichier .cpuprofile chargeable dans Chrome DevTools
# Heap profile (sampling) — coût très faible, OK en prod sur sample
node --heap-prof --heap-prof-dir=./profiles server.js// continuous-profiling-pyroscope.ts
import Pyroscope from '@pyroscope/nodejs';
Pyroscope.init({
serverAddress: 'https://pyroscope.example.com',
appName: 'my-service',
tags: { env: process.env.NODE_ENV ?? 'dev', region: 'eu-west' },
// wall + CPU profiling activés ; le sampling V8 tourne à ~100 Hz par défaut
wall: { samplingDurationMs: 10_000, samplingIntervalMs: 10 },
});
Pyroscope.start();
// Pyroscope ouvre un agent in-process qui sample CPU et heap, envoie au serveur
// labels dynamiques (par tenant/route) sans relancer l'agent :
// Pyroscope.wrapWithLabels({ tenant: tenantId }, () => handler(req, res));// programmatic-cpu-profile.ts — démarrer/arrêter un profil par signal HTTP
import { Session } from 'node:inspector/promises';
import { writeFile } from 'node:fs/promises';
const session = new Session();
session.connect();
await session.post('Profiler.enable');
app.post('/debug/cpu-profile/start', async (req, res) => {
await session.post('Profiler.start');
res.status(202).json({ status: 'started' });
});
app.post('/debug/cpu-profile/stop', async (req, res) => {
const { profile } = await session.post('Profiler.stop');
await writeFile(`/tmp/profile-${Date.now()}.cpuprofile`, JSON.stringify(profile));
res.json({ status: 'saved' });
});// programmatic-heap-snapshot.ts — heap snapshot sur demande
import v8 from 'node:v8';
import { createWriteStream } from 'node:fs';
app.post('/debug/heap-snapshot', (req, res) => {
const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
const stream = createWriteStream(filename);
v8.getHeapSnapshot().pipe(stream);
stream.on('finish', () => res.json({ filename }));
});
// IMPORTANT: protéger cette route par auth, et la désactiver sauf besoin// eventloop-utilization.ts — mesure relative CPU vs idle
import { performance } from 'node:perf_hooks';
let lastELU = performance.eventLoopUtilization();
setInterval(() => {
const elu = performance.eventLoopUtilization(lastELU);
// elu.utilization entre 0 et 1 : 0 = boucle idle, 1 = boucle saturée
console.log(`event loop utilization: ${(elu.utilization * 100).toFixed(1)}%`);
lastELU = performance.eventLoopUtilization();
}, 5000);// timerify.ts — wrapper automatique pour mesurer une fonction
import { performance, PerformanceObserver } from 'node:perf_hooks';
function slowJob(input: number): number {
let s = 0;
for (let i = 0; i < input * 1e6; i++) s += Math.sqrt(i);
return s;
}
// timerify retourne une version wrappée qui émet des PerformanceEntry
const measured = performance.timerify(slowJob);
const obs = new PerformanceObserver((items) => {
for (const entry of items.getEntries()) {
console.log(`${entry.name}(${entry.detail}): ${entry.duration.toFixed(2)}ms`);
}
});
obs.observe({ entryTypes: ['function'] });
measured(100);// custom-marks.ts — marquer une route entière + sous-segments
import { performance } from 'node:perf_hooks';
app.use((req, res, next) => {
const start = performance.now();
res.on('finish', () => {
const duration = performance.now() - start;
if (duration > 1000) {
console.warn(`SLOW request ${req.method} ${req.url}: ${duration.toFixed(0)}ms`);
}
});
next();
});🎯 Patterns courants
1. Avoir une baseline. Un profil n'a de sens que comparé. Avant d'optimiser, on capture un profil "before". Après l'optimisation, on capture "after" sous la même charge et on diff. Sans baseline, on optimise dans le vide. La baseline doit inclure la distribution complète des latences (p50, p90, p95, p99, p99.9) et pas juste un seul nombre. Une optim peut améliorer la médiane et dégrader la p99 — c'est typique d'un changement qui réduit le coût moyen mais augmente la variance.
2. Profiler sous charge représentative. Profiler node server.js avec un seul curl ne dit rien d'utile. Lancer autocannon ou wrk qui simule la production (concurrence, payload size, ratio des routes). npx autocannon -c 50 -d 30 -m POST -b '{...}' http://....
3. Mesurer la p99, pas la moyenne. La moyenne masque les queues. Si 1 % des requêtes prend 5 s à cause d'un GC pause, la moyenne reste basse mais l'expérience utilisateur est mauvaise. Toujours regarder p50/p95/p99/p999.
4. Event loop lag = signal n°1. Avant de plonger dans un flamegraph, vérifier l'event loop lag. Si p99 du lag > 100 ms, on a un blocage CPU dans la boucle qu'il faut localiser. Un lag faible mais une latence haute = c'est du I/O ou du downstream. Le lag étant un histogramme, on regarde p50 (~ 1-2 ms en sain), p99 (< 50 ms en bonne santé), max (signal d'alerte si > 500 ms même ponctuellement). En dashboard Grafana, plotter les trois en stack, alerter sur p99 > 100 ms pendant 5 minutes consécutives.
5. Flamegraph self vs total. Sur un flamegraph, regarder le self time (largeur du sommet de stack) plutôt que le total. Une fonction "wrapper" peut apparaître large parce qu'elle appelle de l'autre, mais c'est ses callees qui consomment. Dans clinic.js, le filtre Show only self time aide à isoler les vrais hot spots. Dans Pyroscope, le "self" et "total" sont affichés séparément par défaut.
6. Heap snapshot — three-snapshot technique. Pour traquer un leak : prendre un snapshot, exécuter l'opération suspecte, snapshot 2, refaire l'opération, snapshot 3. Comparer 2 vs 3 — les objets qui apparaissent dans les deux deltas sont les leakers. Dans Chrome DevTools, la vue "Comparison" affiche les objets ajoutés/supprimés entre deux snapshots ; filtrer par "Constructor" pour identifier le type qui grossit. Cliquer sur un objet montre son retainer path — la chaîne de références qui empêche le GC de le collecter.
7. Sourcemaps désactivées en profil prod. En prod, on profile le code minifié pour refléter la réalité. Si les sourcemaps sont actives, V8 décompresse à chaque sample, ce qui ralentit. On les active ponctuellement pour identifier, puis on les coupe.
8. Continuous profiling à 1-3 % d'overhead. Pyroscope, Sentry Profiling, Datadog Continuous Profiler échantillonnent à basse fréquence (10-100 Hz) et envoient les profils agrégés. Coût négligeable, vue continue, comparaison entre versions facile.
9. Anonymisation des profils. Un profil contient des noms de fonctions, des chemins de fichiers, parfois des contenus de chaînes (via les noms de variables). En prod, vérifier que les noms de variables sensibles n'apparaissent pas, que les chemins n'exposent pas la structure interne. Sentry et Datadog masquent par défaut.
10. Profiler en mode prod-like. NODE_ENV=production, --max-old-space-size réaliste, dépendances en mode prod (pas de hot reload). Sinon on profile des artefacts (sourcemaps, devtools hooks, etc.) qui n'existent pas en vrai.
11. Annoter les marks avec du contexte. performance.mark('route:/users:start') permet de filtrer ensuite. Avec PerformanceObserver, on agrège par mark name et on identifie les routes lentes sans tracer chaque appel.
12. Histograms over averages. monitorEventLoopDelay retourne un histogramme. On expose p50/p99/max via Prometheus, on alerte sur p99 > 100 ms. La moyenne d'un lag varie peu — c'est la queue qui informe.
13. Profil en différentiel. Quand on optimise, comparer deux profils sous la même charge : avant/après. Pyroscope, Sentry et clinic permettent de diff visuellement. C'est la seule façon honnête de prouver qu'un changement améliore vraiment (et de combien).
14. Cluster vs single-process pour profiler. Si on tourne en cluster (PM2, k8s avec plusieurs replicas), profiler une seule instance est représentatif si la charge est bien répartie. En continuous profiling, on tag les profils par instance/pod pour pouvoir identifier des outliers (un worker spécifique qui souffre alors que les autres vont bien).
15. Profiler le démarrage. Les cold starts (Lambda, k8s pods) sont parfois 10× plus lents que la moyenne à cause de la compilation V8. --cpu-prof capture le démarrage. --build-snapshot permet de précompiler une partie du code pour accélérer le boot. Pour un service avec scaling fréquent, c'est un levier critique.
16. Mesurer l'overhead du profiling lui-même. Activer le profiling en prod doit coûter moins de 3 % CPU. Vérifier avec une charge de référence : profile off vs profile on, regarder la différence de throughput. Si l'overhead est > 5 %, baisser la fréquence d'échantillonnage.
17. Tracer avec OpenTelemetry pour corréler. Un profil dit "où le CPU passe son temps", mais ne dit pas "sur quelle requête". Avec OTel, les spans sont annotés avec un trace_id qu'on retrouve dans les logs et dans les profils Pyroscope/Sentry (qui supportent l'exemplar linking). On clique sur un point lent dans le profil, on tombe sur la trace correspondante.
🔄 Versions — Node 18 / 20 / 22 / 24
Node 16 (rappel, EOL septembre 2023). Version où --cpu-prof et --heap-prof (introduits en Node 12) deviennent largement utilisés. performance.eventLoopUtilization (apparu en Node 14.10) est stable et courant à ce stade.
Node 18. performance.timerify, performance.eventLoopUtilization(), PerformanceObserver stables. --cpu-prof et --heap-prof flags natifs. Première version à supporter monitorEventLoopDelay avec une bonne résolution. node --inspect ouvre désormais un DevTools dédié sans avoir besoin du Chrome Inspector legacy.
Node 20. node:test stable, intégration avec les benchmarks via bench (expérimental). Améliorations sur les async stack traces dans les profils — moins de "(anonymous)" parasites. --diagnostic-report génère un rapport JSON complet sur signal ou OOM.
Node 22. process.report enrichi, contient un snapshot heap léger. --inspect-wait qui attend qu'un debugger se connecte sans break. Améliorations majeures sur la lisibilité des flamegraphs grâce à des noms de stack plus précis (V8 12.x).
Node 24. Performance API Level 2 plus complète, alignement avec Web Performance. eventLoopUtilization plus précise, expose CPU vs idle. --build-snapshot et --snapshot-blob permettent de réduire le cold-start, utile pour profiler des cold paths. Continuous profiling natif expérimental via node:inspector (sans dépendre d'un agent externe). Les profils CPU intègrent désormais des informations sur Maglev (compilateur intermédiaire V8) ce qui aide à comprendre les transitions Sparkplug → Maglev → TurboFan dans les hot paths.
Sur l'outillage tiers : clinic.js reste un excellent point de départ pour le local. 0x est plus rapide à exécuter mais demande de comprendre perf (Linux) ou dtrace (macOS). autocannon est devenu le défaut pour la charge HTTP en Node. pino (logger) intègre perf_hooks pour mesurer les temps de log. OpenTelemetry couvre tracing + profiling pour une stack observability complète.
⚠️ Pitfalls — 6-10
1. Profiler dev au lieu de prod. Le code dev avec sourcemaps, dev middlewares, hot reload, n'a rien à voir avec le profil prod. Toujours profiler avec NODE_ENV=production et le bundle final.
2. Charge non représentative. 1 req/s sur localhost ne révèle ni les contentions DB, ni le GC, ni les blocages d'event loop. Reproduire la charge avec autocannon/wrk/k6 à un débit proche de la prod.
3. Lire un flamegraph sans baseline. "Cette fonction prend 30 % du CPU" — c'est beaucoup ? peu ? Comparer à un profil baseline ou à un profil avant changement.
4. Confondre self et total time. Un parent qui empile 20 enfants prend une grande barre, mais le hot spot est souvent dans une feuille.
5. Ignorer le GC. Les pauses GC apparaissent comme (garbage collector) dans les flamegraphs. Si > 5-10 % du CPU, c'est un signal d'optimisation mémoire (réduction d'allocations, pooling).
6. Heap snapshot pris au mauvais moment. Juste après démarrage, le heap est petit, rien de leak visible. Il faut laisser tourner sous charge, puis snapshot, puis re-charger, puis re-snapshot.
7. Continuous profiling sans filtrage. Envoyer 100 % des samples à un serveur central peut coûter cher en bande passante et en stockage. Sampling à 10-20 Hz suffit largement.
8. Inspect ouvert en prod. --inspect expose le port 9229. Sans firewall, c'est un RCE direct (eval arbitraire via DevTools). Toujours bind sur 127.0.0.1, jamais sur 0.0.0.0, et désactiver en prod.
9. Sourcemaps oubliées en prod profiling. Sans sourcemaps, les flamegraphs montrent des noms minifiés (t, a, b). En continuous profiling, configurer l'upload des sourcemaps vers le service (Sentry, Datadog).
10. Mesurer trop tôt. Profiler la première seconde du process, c'est mesurer le warm-up (compilation V8, lazy loading des modules). Toujours warm-up le serveur 30-60 s avant de mesurer.
11. Confondre wall time et CPU time. Une fonction peut prendre 500 ms de wall time mais 5 ms de CPU si elle fait du I/O. Le flamegraph V8 montre du CPU time côté JS — il ne voit pas l'attente sur un socket. Pour comprendre où le temps réel passe, croiser avec des traces OpenTelemetry qui couvrent les durées effectives.
12. Heap snapshot trop volumineux pour DevTools. Au-delà de 2-4 GB de heap, Chrome DevTools rame ou crash. Préférer heapsnapshot-parser en CLI, ou heap-profile qui est plus compact. Pour de très gros heaps, faire le snapshot sur une instance dédiée hors prod et analyser offline.
13. Profiler bloque le worker. Si on profile un worker thread, le profil capture l'activité de ce worker. Le main reste libre, mais le worker subit l'overhead. Ne pas profiler tous les workers en même temps en prod.
14. Échantillons biaisés par le scheduler OS. Sur une machine chargée, l'OS peut suspendre Node pour exécuter d'autres processus. V8 ne sait pas qu'il a été suspendu, donc les samples peuvent paraître plus uniformes qu'en réalité. Profiler sur une machine quiesce ou avec taskset pour pinner.
15. Profiler la mauvaise instance. En cluster ou k8s, profiler une seule instance peut masquer un problème spécifique à une autre. Vérifier que la charge est répartie équitablement avant de tirer des conclusions. Si on a un load balancer sticky, un seul client peut être routé sur une seule instance qui rame — l'autre est OK.
16. Lire un profil "en isolation". Un profil dit "fonction X = 30 %". Mais 30 % de quoi ? Du CPU utilisé. Si le CPU global est à 10 %, fonction X consomme 3 % du CPU total. Toujours croiser avec le CPU global (Prometheus) pour calibrer.
17. Faux positif sur des fonctions polymorphes. V8 a un système de hidden classes qui optimise les objets dont la forme est stable. Une fonction qui reçoit des objets de formes différentes est dégradée. Dans un profil, ça apparaît parfois comme du temps passé en InlineCache miss. La fix : stabiliser la forme des objets (toujours mêmes propriétés dans le même ordre).
18. Ne pas savoir distinguer cause et symptôme. Le GC qui prend 30 % du CPU n'est pas la cause, c'est le symptôme. La cause est dans les allocations excessives. Le profil heap répond à "qui alloue", le profil CPU répond à "où ça brûle" — les deux ensemble donnent le diagnostic.
🧪 Testing — node --test, benchmarks
// bench/json-parse.bench.ts — micro-benchmark contrôlé
import { performance } from 'node:perf_hooks';
const payload = JSON.stringify({ items: Array.from({ length: 10_000 }, (_, i) => ({ i })) });
function bench(name: string, fn: () => void, iter = 1000) {
// warm-up
for (let i = 0; i < 100; i++) fn();
const t0 = performance.now();
for (let i = 0; i < iter; i++) fn();
const t1 = performance.now();
console.log(`${name}: ${((t1 - t0) / iter).toFixed(3)} ms/op`);
}
bench('JSON.parse', () => JSON.parse(payload));
bench('JSON.parse + spread', () => ({ ...JSON.parse(payload) }));// tests/event-loop.test.ts — vérifier qu'on ne bloque pas la boucle
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { monitorEventLoopDelay } from 'node:perf_hooks';
test('expensive function does not block event loop > 50ms', async () => {
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
// exécuter la fonction sous test
await heavyAsyncWork();
h.disable();
const p99 = h.percentile(99) / 1e6;
assert.ok(p99 < 50, `event loop p99 = ${p99}ms`);
});# Charge + profil + flamegraph en une commande
npx clinic flame --on-port 'autocannon -c 100 -d 20 http://localhost:$PORT' -- node server.js
# clinic démarre le serveur, attend qu'il listen, lance autocannon, arrête, génère le rapportPour benchmarker proprement, isoler la fonction sous test, warm-up suffisant (1000+ itérations), exécuter sur une machine quiesce (rien d'autre ne tourne), faire 3-5 runs et prendre la médiane. Les micro-benchmarks Node sont sensibles à l'ordre d'exécution à cause de l'inlining V8.
// bench/route-comparison.bench.ts — comparer deux implémentations sous charge
import autocannon from 'autocannon';
async function bench(label: string) {
const result = await autocannon({
url: 'http://localhost:3000/items',
connections: 50,
duration: 30,
});
console.log(label, {
rps: result.requests.average,
p99_ms: result.latency.p99,
errors: result.errors,
});
}
await bench('before optim');
// déployer la nouvelle version, attendre warm-up
await bench('after optim');// scripts/event-loop-monitor.ts — exposer event loop lag en Prometheus
import { monitorEventLoopDelay } from 'node:perf_hooks';
import { register, Gauge } from 'prom-client';
const histo = monitorEventLoopDelay({ resolution: 20 });
histo.enable();
const p50 = new Gauge({ name: 'event_loop_lag_p50_ms', help: '...' });
const p99 = new Gauge({ name: 'event_loop_lag_p99_ms', help: '...' });
const max = new Gauge({ name: 'event_loop_lag_max_ms', help: '...' });
setInterval(() => {
p50.set(histo.percentile(50) / 1e6);
p99.set(histo.percentile(99) / 1e6);
max.set(histo.max / 1e6);
histo.reset();
}, 10_000);
// Endpoint /metrics expose tout ça à PrometheusPour les benchmarks CI, attention à l'instabilité des runners cloud (GitHub Actions, GitLab CI) : la machine partagée varie de 2-3× en performance brute. Pour des micro-bench stables, utiliser des runners dédiés ou ne pas se baser sur des chiffres absolus mais sur des ratios (op A vs op B sur la même machine au même instant). tinybench est une lib qui gère la variance et propose des écarts-types pour ces comparaisons.
🎬 Cas d'usage concrets
Scénario 1 — API banque P99 debug post-deploy
Banque déploie une nouvelle version du service scoring antifraude. P50 stable mais P99 passe de 80 ms à 410 ms. Le service répond toujours dans le SLA, mais 1 % des virements SEPA Instant tombent au-delà du seuil réglementaire — alerte rouge en astreinte.
Investigation : node --inspect=0.0.0.0:9229 activé en prod sur un seul pod (load balancer dégage temporairement le pod du flux principal, le garde sur un trafic miroir). Capture profil CPU 60 s via Chrome DevTools, flame graph montre 38 % du temps dans un RegExp.prototype.test sur un nouveau parser IBAN — pattern catastrophique avec backtracking exponentiel sur certains IBANs étrangers.
Confirmation via perf_hooks.monitorEventLoopDelay : histogram montre des spikes à 380 ms corrélés temporellement avec les requêtes IBAN AT/DE. Fix : regex réécrite sans alternation imbriquée, P99 retombe à 90 ms. Le profil et le histogram sont archivés dans le post-mortem comme preuves quantitatives.
Scénario 2 — E-commerce slow checkout profile
E-commerce qui constate un checkout lent (FCP côté front ok, mais le POST /checkout/confirm prend 1.2 s en moyenne, P99 à 4 s). Aucun changement récent, mais le volume a doublé depuis une campagne marketing.
clinic doctor lancé sur un noeud staging avec replay des requêtes prod : verdict "I/O bottleneck", probablement DB. clinic bubbleprof ensuite confirme : pattern asynchrone montre une chaîne awaits séquentielle dans le validator de stock (1 query par item dans le panier, panier moyen 8 items = 8 RTT séquentiels DB).
Refactor : passage en Promise.all parallèle + un seul SELECT ... WHERE id IN (...) côté repo. Re-bench : checkout median 280 ms, P99 720 ms. 0x flamegraph confirme que le CPU n'est plus dominé par pg.Pool.connect (qui montrait l'attente connexions saturées).
Scénario 3 — Cabinet juridique génération rapport bottleneck
Cabinet qui génère un rapport client trimestriel (PDF de 200 pages : timeline dossiers, statistiques, exports facturation). Génération nominale 12 s, mais sur certains clients > 90 s. CPU full pendant tout le temps.
--prof activé sur l'instance dédiée rapport, retraitement via node --prof-process : 62 % du temps dans htmlMinifier.minify. Inspection : le template HTML produit pour les gros clients dépasse 20 MB avant minification (logs détaillés inlinés à tort par erreur de template).
Fix : streaming HTML → PDF via Puppeteer en lieu et place de la concaténation in-memory + minification. Mémoire RSS divisée par 6 (520 MB → 85 MB), génération en 14 s constant quel que soit le client. --cpu-prof automatisé en CI sur le worker rapport pour détecter toute régression > 10 % sur le path critique.
🛠️ Exemple end-to-end
Service instrumenté de bout en bout : event-loop monitoring, profil CPU on-demand via signal, métriques Prometheus, exposition contrôlée des helpers de diagnostic.
import { createServer } from "node:http";
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
import { Session } from "node:inspector/promises";
import { writeFile } from "node:fs/promises";
const eventLoop = monitorEventLoopDelay({ resolution: 10 });
eventLoop.enable();
const counters = {
requests: 0,
errors: 0,
totalLatencyMs: 0,
};
let cpuProfiling: { session: Session; startedAt: number } | null = null;
async function startCpuProfile() {
if (cpuProfiling) return;
const session = new Session();
session.connect();
await session.post("Profiler.enable");
await session.post("Profiler.start");
cpuProfiling = { session, startedAt: Date.now() };
console.log("[diag] cpu profile started");
}
async function stopCpuProfile(): Promise<string> {
if (!cpuProfiling) throw new Error("no profile running");
const { session, startedAt } = cpuProfiling;
cpuProfiling = null;
const { profile } = (await session.post("Profiler.stop")) as { profile: unknown };
await session.post("Profiler.disable");
session.disconnect();
const filename = `/tmp/cpu-${startedAt}.cpuprofile`;
await writeFile(filename, JSON.stringify(profile));
console.log(`[diag] cpu profile written to ${filename}`);
return filename;
}
// trigger profiling via SIGUSR2 (safe-ish for ops)
process.on("SIGUSR2", () => {
if (cpuProfiling) {
stopCpuProfile().catch((err) => console.error(err));
} else {
startCpuProfile().catch((err) => console.error(err));
}
});
const server = createServer(async (req, res) => {
const t0 = performance.now();
counters.requests++;
if (req.url === "/metrics") {
const p50 = eventLoop.percentile(50) / 1e6;
const p99 = eventLoop.percentile(99) / 1e6;
res.setHeader("content-type", "text/plain");
return res.end(
[
`# HELP node_eventloop_lag_p50_ms event loop lag P50 in ms`,
`# TYPE node_eventloop_lag_p50_ms gauge`,
`node_eventloop_lag_p50_ms ${p50.toFixed(2)}`,
`node_eventloop_lag_p99_ms ${p99.toFixed(2)}`,
`app_requests_total ${counters.requests}`,
`app_errors_total ${counters.errors}`,
`app_avg_latency_ms ${(counters.totalLatencyMs / Math.max(1, counters.requests)).toFixed(2)}`,
].join("\n")
);
}
// simulate work
try {
await new Promise((r) => setTimeout(r, 5));
res.end("ok");
} catch {
counters.errors++;
res.statusCode = 500;
res.end();
} finally {
counters.totalLatencyMs += performance.now() - t0;
}
});
server.listen(3000, () => console.log("listening on :3000"));Usage opérateur sous incident :
# 1. observe metrics
curl -s localhost:3000/metrics | grep eventloop
# 2. start CPU profile during the spike
kill -SIGUSR2 $(pgrep -f node-service)
# 3. wait 30-60s capturing the bad behaviour
sleep 30
# 4. stop and grab the profile
kill -SIGUSR2 $(pgrep -f node-service)
ls -la /tmp/cpu-*.cpuprofile
# 5. open it in Chrome DevTools (Performance tab > Load profile)Points clés : monitorEventLoopDelay exposé en /metrics, profil CPU démarrable via signal sans redémarrer (utile en prod), reset des compteurs explicite si nécessaire, écriture du .cpuprofile chargeable directement dans DevTools, pas de dépendance externe.
🔁 Quand utiliser / éviter
Utiliser --inspect + DevTools quand : on debug en local, on veut un profil CPU détaillé interactif, on cherche un breakpoint conditionnel.
Utiliser clinic.js quand : on cherche un diagnostic guidé, on n'est pas sûr de quel problème on a (CPU ? memory ? async ? event loop ?).
Utiliser 0x quand : on veut un flamegraph rapide sur un binaire Linux, ou intégré à un pipeline CI.
Utiliser perf_hooks quand : on veut instrumenter le code en continu, exposer des métriques applicatives custom.
Utiliser continuous profiling (Pyroscope, Sentry, Datadog) quand : on a un service en prod, on veut comparer des profils entre versions, on veut identifier des régressions de perf sans intervention manuelle.
Éviter de profiler quand : pas de problème mesuré côté SLO. L'optimisation prématurée est le mal racine. Mesurer d'abord les SLI (latence p99, throughput, error rate), profiler seulement si on dépasse les seuils.
Éviter --inspect en prod : trou de sécurité.
Éviter heap snapshot en prod sur process busy : prendre un snapshot fait un STW (stop-the-world) GC, le process est gelé pendant la durée de la capture. À faire en off-hours ou sur une instance retirée du LB.
Critères de décision rapides. SLO violé sur latence p99 → mesurer event loop lag d'abord. Lag élevé → CPU profile pour identifier le blocage. Lag normal mais latence élevée → c'est du I/O downstream, profiler avec tracing OpenTelemetry. Heap qui grimpe → heap snapshot + diff. Cold start lent → CPU profile au démarrage + envisager --build-snapshot. Pour tout ça, avoir Pyroscope/Sentry/Datadog en continu évite d'avoir à courir derrière les profils au moment du problème.
Continuous profiling : ROI. Le coût d'un agent comme Pyroscope-Node ou Sentry Profiling est de l'ordre de 1-2 % CPU avec un sampling à 100 Hz. La valeur : capacité à diff deux versions, à identifier des régressions en 30 secondes au lieu de 30 minutes de debug, à corréler profils + traces + logs. Pour un service en prod sérieux, c'est aussi essentiel que les métriques Prometheus.
Profiler avec anonymisation. En SaaS multi-tenant, les profils peuvent fuiter des indices sur la structure client (noms de fichiers, paths). Les agents modernes (Sentry, Datadog) anonymisent par défaut, mais vérifier. Pour les heap snapshots qui peuvent contenir des contenus de string en clair, ne jamais les uploader vers un service tiers sans accord sécurité ; les analyser localement.
Lecture détaillée d'un flamegraph clinic. Exemple type d'investigation. Le rapport clinic flame s'ouvre dans le navigateur ; on voit une barre racine (top) puis des barres empilées. Les couleurs sont arbitraires (différencier visuellement). On clique sur une barre pour zoomer dedans. Si on voit (garbage collector) représenter plus de 10 % du temps, on a un problème mémoire à traiter avant le CPU. Si on voit JSON.parse ou JSON.stringify représenter 20 %, on cherche à streamer ou à utiliser simdjson pour les très gros payloads. Si on voit une fonction métier en haut de pile, on lit son code pour trouver une boucle inefficace ou une regex catastrophique.
Pattern : détection de regex catastrophiques. Les regex avec backtracking peuvent prendre des secondes sur une entrée mal choisie (/^(a+)+$/ sur "aaaaaaaaaaaa!"). Dans un flamegraph, ces regex apparaissent comme des barres très larges sur regexp.compile ou regexp.exec. La détection automatique est possible via safe-regex ou en mesurant le temps de chaque regex avec un wrapper. Régression de sécurité : un attaquant peut exploiter ça pour faire une ReDoS (regex denial of service).
Pattern : identifier les allocations excessives. Le heap profile (sampling d'allocations) montre quelles fonctions allouent le plus en cumul. Une fonction qui crée un objet par requête × 1000 RPS = 1000 objets/s à GC. Optimisations courantes : pré-allouer, réutiliser des objets via pool, préférer for à map/filter chainés (qui créent des arrays intermédiaires) sur les hot paths.
Pattern : continuous profiling diff entre deux deployments. Avec Pyroscope/Sentry, on peut comparer deux time ranges directement dans l'UI : avant déploiement vs après. Les fonctions qui ont vu leur self time augmenter apparaissent en rouge, celles qui ont diminué en vert. C'est ainsi qu'on détecte les régressions de perf en quelques minutes sans avoir à profiler manuellement.
Pattern : profiler par tenant en SaaS. En multi-tenant, certains clients sont plus consommateurs que d'autres. Avec OpenTelemetry + AsyncLocalStorage, on tag chaque trace avec un tenant_id. Pyroscope (et autres) peuvent ensuite filtrer les profils par tag. On découvre alors que le tenant X consomme 30 % du CPU à lui seul à cause d'un usage particulier, et on agit (rate limit, tier dédié, optimisation ciblée).
Pattern : sampling adaptatif. Au lieu d'échantillonner uniformément à 100 Hz, certains agents adaptent le taux : haut sur les requêtes anormalement lentes, bas sur les requêtes triviales. Ça économise du stockage tout en gardant la résolution là où elle compte. Configurable dans Sentry Profiling via la traces_sampler callback.
Pattern : profil par environnement. Un profil dev/staging ne reflète pas la prod. Les caches sont froids, les données sont différentes, le trafic est synthétique. Les conclusions tirées d'un profil non-prod doivent être vérifiées en prod via continuous profiling avant d'être considérées comme acquises.
Pattern : alerter sur les régressions de perf en CI. On peut intégrer des benchmarks dans la CI : autocannon sur la branche main vs la PR, fail si p99 augmente de plus de 10 %. C'est moins fiable que la prod mais détecte les régressions évidentes avant merge. Tools : benchmark.js, tinybench, mitata (rapide et précis).
🏋️ Exercices
Ces exercices sont conçus pour être faits dans l'ordre. Chacun part d'un service Node réel (Express/Fastify ou node:http), pas d'un toy script. Travaille toujours avec une charge représentative (autocannon -c 50 -d 30) et une baseline capturée avant toute modification.
Exercice 1 — Localiser un blocage event loop (implement)
Objectif : instrumenter un service de façon à détecter, en moins de 10 secondes, qu'un endpoint bloque la boucle d'événements, et identifier lequel.
Construis un service avec trois routes : /fast (réponse immédiate), /io (un await setTimeout(100)), /cpu (une boucle synchrone de 200 ms — par exemple un JSON.parse sur un payload de 5 MB en boucle). Expose monitorEventLoopDelay (p50/p99/max) et eventLoopUtilization sur /metrics. Lance autocannon simultanément sur /io et /cpu et observe : montre que /io ne fait pas monter le lag mais que /cpu le fait exploser, alors que les deux ont une latence wall-clock comparable.
Indice / Solution
Le piège conceptuel : /io et /cpu ont la même latence wall-clock (~100-200 ms) mais des signatures profil opposées. /io rend la main à la boucle pendant l'attente → ELU bas, lag bas. /cpu la monopolise → ELU proche de 1.0, lag p99 ≈ durée du tick bloquant. C'est exactement la distinction "wall time vs CPU time" du pitfall #11.
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
let lastELU = performance.eventLoopUtilization();
// dans /metrics :
const elu = performance.eventLoopUtilization(lastELU);
lastELU = performance.eventLoopUtilization();
// expose h.percentile(99)/1e6 et elu.utilizationPour distinguer quel endpoint bloque sans tracing complet : tag chaque requête via AsyncLocalStorage, et au moment où le lag dépasse un seuil, log le tenant/route du contexte courant. Mieux : corréler avec un CPU profile (exercice 3).
Exercice 2 — Three-snapshot leak hunt (implement → diagnose)
Objectif : reproduire une fuite mémoire réaliste, la confirmer par la technique des trois heap snapshots, et identifier le retainer path exact.
Écris une route qui, à chaque appel, pousse un objet dans une Map module-level indexée par un timestamp (un cache "oublié" sans éviction — le leak le plus courant en prod). Mets en place une route /debug/heap-snapshot protégée. Capture snapshot 1, envoie 10 000 requêtes, snapshot 2, renvoie 10 000 requêtes, snapshot 3. Charge les trois dans Chrome DevTools (vue Comparison 2→3), identifie le constructeur qui grossit, et remonte le retainer path jusqu'à la Map. Puis corrige avec une LRU bornée (lru-cache) ou un setInterval d'éviction, et prouve par un quatrième snapshot que le heap se stabilise.
Indice / Solution
Les objets présents dans les deux deltas (2→3 et au-delà) sont les vrais leakers — ceux qui ne sont jamais collectés. Dans DevTools, filtre par "Constructor", trie par "Retained Size", clique sur une instance et lis la section "Retainers" du bas vers le haut : tu dois voir (closure) → Map → (global / module). Si tu vois plutôt un chemin vers une Timeout ou un EventEmitter, c'est un listener jamais détaché — l'autre grande famille de leaks.
import { LRUCache } from "lru-cache";
const cache = new LRUCache<string, Entry>({ max: 10_000, ttl: 60_000 });Pour automatiser la détection en prod sans DevTools : process.memoryUsage().heapUsed qui croît monotone sur plusieurs cycles GC (force un global.gc() avec --expose-gc entre les mesures) est le signal.
Exercice 3 — Profil CPU on-demand sans redémarrage (production-grade)
Objectif : capturer un CPU profile en prod, sur le pod incriminé, sans redéployer ni redémarrer, et de façon sécurisée.
Reprends le pattern SIGUSR2 / node:inspector/promises de l'exemple end-to-end. Durcis-le pour la prod : (1) la capture ne doit jamais pouvoir tourner indéfiniment (auto-stop après 60 s même si aucun second signal n'arrive) ; (2) deux signaux rapprochés ne doivent pas corrompre l'état (idempotence / lock) ; (3) le .cpuprofile doit atterrir dans un répertoire à permissions restreintes, pas /tmp monde-lisible ; (4) mesure l'overhead réel de la capture en comparant le throughput autocannon profil-off vs profil-on. Vérifie que le surcoût reste sous 5 %.
Indice / Solution
Auto-stop : démarre un setTimeout(stop, 60_000).unref() à l'activation pour ne pas tenir le process en vie. L'unref() est essentiel — sinon ton timer empêche un shutdown propre. Garde l'état dans un seul objet cpuProfiling | null et garde une garde en tête de start/stop (le if (cpuProfiling) return de l'exemple est déjà la base de l'idempotence).
Overhead : le CPU profiler V8 par défaut échantillonne à ~1 ms (1000 Hz). Pour de la prod, relâche l'intervalle avec Profiler.setSamplingInterval (par ex. 1000 µs → 200 µs n'est PAS ce que tu veux ; augmente plutôt à 1000-2000 µs pour baisser la fréquence) avant Profiler.start. Mesure : autocannon -c 50 -d 30 deux fois, compare requests.average. Un overhead < 5 % à 1 ms est attendu sur un service I/O-bound ; sur un service CPU-bound, descends la fréquence.
Sécurité : --inspect n'est PAS requis ici — new Session() ouvre une session inspector in-process sans exposer le port 9229. C'est le gros avantage de cette approche vs node --inspect=0.0.0.0 (qui est un RCE, pitfall #8).
Exercice 4 — Tuer une ReDoS, puis prouver le fix (break → fix)
Objectif : exploiter une regex catastrophique pour saturer un pod (déni de service mono-requête), la localiser au flamegraph, puis prouver quantitativement le fix.
Ajoute un endpoint de validation qui utilise une regex vulnérable au backtracking, par exemple /^(\w+\s?)*$/ ou un parser d'email naïf. Envoie une seule requête avec une entrée pathologique ("a".repeat(50) + "!") et observe : le pod ne répond plus à aucune requête (un seul thread, boucle bloquée). Capture un CPU profile pendant l'attaque (clinic flame ou 0x) et montre la barre large sur RegExp.exec. Corrige : regex réécrite sans alternation imbriquée, ou bascule sur un parser linéaire (ou la RE2 de node-re2 qui garantit O(n)). Prouve avec monitorEventLoopDelay que le lag p99 ne bouge plus sous la même entrée.
Indice / Solution
Le coût d'une ReDoS est exponentiel en longueur d'entrée : 50 caractères peuvent prendre plusieurs secondes, 60 plusieurs minutes. C'est le scénario 1 du cas bancaire IBAN. Au flamegraph V8, ça apparaît comme une barre quasi pleine sur RegExp.prototype.[Symbol.replace] ou regexp builtin, sans descendre dans du code JS — le backtracking se passe dans le moteur natif.
Fixes, par ordre de robustesse : (1) supprimer l'alternation/quantificateur imbriqué ((\w+\s?)* → \w+(\s\w+)*) ; (2) borner la longueur d'entrée AVANT le match (if (input.length > 256) reject) — défense en profondeur ; (3) node-re2 (binding RE2) qui n'a pas de backtracking, garantie linéaire, au prix de ne pas supporter les lookbehind. Détection préventive : safe-regex ou recheck en CI sur toutes les regex du code. Pour Node 20+, surveille aussi --disallow-code-generation-from-strings côté durcissement général.
Exercice 5 — Diff continuous profiling avant/après (production-grade, capstone)
Objectif : monter une boucle de validation de perf complète — baseline en continuous profiling, optimisation, et diff visuel prouvant le gain self-time, sans nombre absolu fragile.
Branche Pyroscope (ou Sentry Profiling) sur le service. Introduis volontairement une régression de perf (par ex. remplace un Promise.all par une chaîne await séquentielle, ou un Set.has par un Array.includes sur un hot path). Déploie en deux versions taguées (version=v1 / version=v2). Dans l'UI Pyroscope, utilise la vue diff entre les deux time-ranges/tags et montre la fonction qui passe au rouge (self-time en hausse). Puis corrige et prouve le retour au vert. Bonus : tag les profils par tenant_id via AsyncLocalStorage et identifie quel tenant porte la régression.
Indice / Solution
L'init Pyroscope moderne tag par labels dynamiques :
import Pyroscope from "@pyroscope/nodejs";
Pyroscope.init({
serverAddress: "https://pyroscope.example.com",
appName: "my-service",
tags: { env: process.env.NODE_ENV ?? "dev", version: process.env.APP_VERSION ?? "dev" },
});
Pyroscope.start();
// labels dynamiques par requête (tenant) :
Pyroscope.wrapWithLabels({ tenant: tenantId }, () => handler(req, res));Le point pédagogique : un diff de profils est la seule preuve honnête d'une optim (pattern #13). Un nombre absolu de p99 dépend de la charge du jour ; un delta self-time entre deux profils pris sur la même fenêtre de trafic est robuste. Rouge = self-time en hausse, vert = en baisse. C'est aussi la mécanique de l'alerte régression en CI : fail si une fonction du hot path gagne > X % de self-time entre main et la PR.
🎤 En entretien
Q : Un endpoint a une latence p99 de 2 s mais le CPU du process est à 15 %. Par où commences-tu ? R : Latence haute + CPU bas = ce n'est pas un hot spot CPU, c'est de l'attente. Je regarde d'abord l'event loop lag : s'il est bas, le blocage est en I/O downstream (DB, HTTP, lock) → tracing OpenTelemetry, pas profiling CPU. Si le lag est haut malgré un CPU process bas, c'est un seul tick qui bloque par intermittence (un JSON.parse géant, une regex catastrophique) — un CPU profile ciblé le révèlera. Un flamegraph CPU seul m'aurait fait perdre des heures car il ne voit pas le wall-time passé en attente sur un socket.
Q : Différence entre un heap snapshot et un heap profile, et quand prendre l'un plutôt que l'autre ? R : Le heap profile (sampling d'allocations, --heap-prof) répond à "qui alloue beaucoup, en cumul" — coût faible, OK en prod, idéal pour la pression GC. Le heap snapshot (v8.getHeapSnapshot) est une photo instantanée de tous les objets vivants — il répond à "qui retient" via les retainer paths et dominateurs, indispensable pour traquer un leak, mais c'est un stop-the-world de plusieurs secondes et une sortie qui peut faire plusieurs GB. En prod : heap profile en continu, snapshot seulement sur une instance retirée du load balancer, en off-hours.
Q : Comment captures-tu un profil CPU en prod sans redémarrer ni ouvrir un trou de sécurité ? R : Surtout pas node --inspect=0.0.0.0 — c'est un RCE (eval arbitraire via DevTools, port 9229). J'ouvre une session inspector in-process via new Session() de node:inspector, déclenchée par un signal (SIGUSR2) ou une route admin authentifiée, avec auto-stop à 60 s et écriture du .cpuprofile dans un répertoire restreint. Aucun port exposé, aucun redémarrage, overhead mesuré sous 5 %. Pour une vue permanente, je préfère un agent de continuous profiling (Pyroscope/Sentry) à 1-3 % CPU plutôt que de courir derrière les incidents.
Q : Pourquoi regarder le self-time plutôt que le total-time sur un flamegraph, et quel piège avec le GC ? R : Le total-time d'une fonction inclut tous ses callees, donc un wrapper fin (un middleware, un Array.map) paraît énorme alors qu'il ne consomme rien lui-même — le vrai hot spot est dans une feuille (self-time large). Pour le GC : il apparaît comme (garbage collector) et c'est un symptôme, pas une cause. S'il dépasse 5-10 % du CPU, le problème est ailleurs — dans les allocations excessives qu'un heap profile, pas le CPU profile, va localiser. CPU profile répond à "où ça brûle", heap profile à "qui alloue" ; il faut les deux pour le diagnostic.
🔗 Liens
- Node.js — Profiling — https://nodejs.org/en/learn/getting-started/profiling
perf_hooksAPI — https://nodejs.org/api/perf_hooks.html- Clinic.js — https://clinicjs.org/
- 0x — https://github.com/davidmarkclements/0x
- Autocannon — https://github.com/mcollina/autocannon
- Pyroscope (continuous profiling) — https://pyroscope.io/
- Sentry Profiling — https://docs.sentry.io/product/profiling/
- Brendan Gregg — Flamegraphs — https://www.brendangregg.com/flamegraphs.html
- Datadog Continuous Profiler — https://docs.datadoghq.com/profiler/
- tinybench — https://github.com/tinylibs/tinybench
- mitata — https://github.com/evanwashere/mitata
Récapitulatif
Le profiling Node tient en quatre couches. Les métriques continues (event loop lag, memory usage, eventLoopUtilization) via perf_hooks exposent l'état global et alertent sur les anomalies. Les profils ponctuels locaux (--inspect, clinic, 0x) répondent à "où est le hot spot" quand on a déjà identifié un problème. Le continuous profiling (Pyroscope, Sentry, Datadog) fournit une vue prod permanente à faible coût, idéal pour les comparaisons inter-versions et le suivi de régressions. Et les benchmarks contrôlés (micro-bench, autocannon, k6) servent à valider une optimisation. La règle d'or : on ne profile pas pour le plaisir ; on profile pour répondre à un SLO violé, et on optimise toujours sur baseline + charge représentative. Surveiller la p99, pas la moyenne. Sécuriser --inspect. Anonymiser les profils. Et toujours, toujours revérifier après optimisation que le gain s'est concrétisé sur la métrique cible.
Le workflow type d'investigation de perf ressemble à ça : (1) Une alerte SLO indique p99 latence dépassée. (2) Vérifier l'event loop lag dans Grafana — s'il est élevé, on a un blocage CPU. (3) Ouvrir Pyroscope/Sentry sur la fenêtre temporelle de l'incident, regarder le top consommateur CPU. (4) Identifier la fonction suspecte, retrouver les traces OpenTelemetry correspondantes pour comprendre le contexte (quelle route, quel payload). (5) Reproduire en local sous charge réaliste avec autocannon. (6) Profiler avec clinic flame ou 0x pour confirmer le hot spot. (7) Implémenter le fix. (8) Bench before/after sur la même charge, vérifier le gain. (9) Déployer en canary, comparer les profils canary vs stable dans Pyroscope. (10) Promouvoir si validation OK.
Sur les anti-patterns à proscrire : profiler en dev avec sourcemaps actifs, mesurer la moyenne au lieu des percentiles, faire un seul run de bench et conclure, ouvrir --inspect en prod sans firewall, ignorer le GC dans les profils, laisser un heap snapshot dans un bucket S3 public. Chacune de ces erreurs a déjà coûté cher à des équipes — la discipline du profiling, c'est aussi la discipline de la sécurité.