Performance — Fastify, cache, profiling, event loop
TL;DR — Nest est rapide ; tes choix le ralentissent. Plus gros leviers en ordre d'impact : (1) Fastify au lieu d'Express (+30–80% throughput), (2) cache HTTP/data approprié, (3) éviter le N+1 (DataLoader), (4) bench avant d'optimiser. Le piège ninja : 99% des "perf issues" Node sont en réalité du event loop lag ou des allocations excessives, pas du CPU pur. Profile avec clinic.js, mesure avec autocannon, regarde
http_server_durationetnodejs_eventloop_lag_seconds.
🧠 Mental model — ASCII diagram + analogy
Client
│
▼
[ HTTP adapter ] ◄── Fastify (uWS-like) vs Express (slower routing)
│
▼
[ Compression ] ◄── only if not behind reverse proxy doing it
│
▼
[ Auth + validation ] ◄── ValidationPipe = real CPU cost
│
▼
[ Controller ]
│
▼
[ Cache check ] ◄── Redis / in-memory LRU
│ miss
▼
[ Service ] ── batching (DataLoader) ─► DB / external APIs
│
▼
[ Response serialization ] ◄── class-transformer is slow, watch out
│
▼
ClientAnalogie : ton app est un restaurant. Fastify = un meilleur planning de salle (sert plus de tables à la fois). Cache = des plats pré-cuits prêts à servir. DataLoader = grouper les courses au lieu d'aller au marché 50 fois. Profiling = installer des caméras pour voir où ça bouchonne. Optimiser sans profiler = changer la cuisine en aveugle.
Règle : never optimize without numbers. Bench avant, bench après, garde les chiffres.
🛠️ Code minimal
Fastify adapter
// main.ts
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ logger: false, trustProxy: true, bodyLimit: 1_048_576 }),
);
await app.listen(3000, '0.0.0.0');Cache layer (Cache Manager + Redis)
// cache.module.ts
import { CacheModule } from '@nestjs/cache-manager';
import { redisStore } from 'cache-manager-redis-yet';
CacheModule.registerAsync({
isGlobal: true,
useFactory: async () => ({
store: await redisStore({ url: process.env.REDIS_URL, ttl: 60_000 }),
}),
});// products.service.ts
@Injectable()
export class ProductsService {
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
async findById(id: string) {
const key = `product:${id}`;
const cached = await this.cache.get<Product>(key);
if (cached) return cached;
const fresh = await this.repo.findOne({ where: { id } });
if (fresh) await this.cache.set(key, fresh, 60_000);
return fresh;
}
}DataLoader pour batcher
// users.loader.ts
import DataLoader from 'dataloader';
@Injectable({ scope: Scope.REQUEST })
export class UsersLoader {
constructor(private readonly repo: UsersRepository) {}
byId = new DataLoader<string, User | null>(async (ids) => {
const users = await this.repo.findByIds([...ids]);
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id) ?? null);
}, { maxBatchSize: 100 });
}REQUEST scope = un DataLoader frais par requête (sinon tu caches entre users → catastrophe).
Event loop lag monitor
// monitoring/event-loop.ts
import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
const p99ms = h.percentile(99) / 1e6;
metrics.histogram('eventloop_lag_p99_ms').record(p99ms);
if (p99ms > 100) logger.warn({ p99ms }, 'event loop lag high');
h.reset();
}, 10_000);Profiling avec clinic.js
npm i -g clinic autocannon
clinic doctor --on-port 'autocannon -c 50 -d 30 localhost:3000/api/orders' -- node dist/main.js
clinic flame --on-port 'autocannon -c 50 -d 30 localhost:3000/api/orders' -- node dist/main.js
clinic bubbleprof -- node dist/main.jsdoctor= diagnostic (CPU vs I/O vs event loop).flame= flamegraph CPU.bubbleprof= visualisation async.
🎯 Patterns courants
- Fastify + lightMyRequest pour les tests e2e → encore plus rapide qu'Express, et le bench reflète la prod.
- Cache stratifié : L1 in-memory LRU par instance (lru-cache, 100 entries, 10s TTL) + L2 Redis (10 min TTL). L1 absorbe les hot keys, L2 partage entre instances.
- Compression au reverse proxy (nginx/CDN), pas dans Node. Sauf si tu sers du gros JSON et que ton CPU est libre.
- DataLoader + GraphQL : indispensable. Sans DataLoader, GraphQL crée du N+1 à chaque champ résolveur.
- Serializer plus rapide que
class-transformer:fast-json-stringify(Fastify-native) avec un schéma précompilé. 3–10x plus rapide. - Streaming pour les gros payloads —
StreamableFile+ cursor DB plutôt que tout charger en RAM. Évite les OOM sur exports CSV/PDF.
Tableau de décision — quelle couche de cache ?
Le réflexe junior est « cache Redis partout ». Le réflexe staff est de raisonner sur cohérence vs latence vs coût d'invalidation.
| Couche | Latence | Partagé entre instances ? | Survit au restart ? | Coût invalidation | Quand l'utiliser |
|---|---|---|---|---|---|
L0 — request-scoped (DataLoader, Map par requête) | ~0 | non | non | nul (meurt avec la requête) | dédupliquer dans une seule requête (N+1) |
L1 — in-process LRU (lru-cache) | ~µs | non | non | facile (purge locale) mais divergence inter-pod | hot keys, TTL courte (1–5s), données read-mostly |
| L2 — Redis | ~0.3–1ms | oui | oui | dur (faut un canal d'invalidation : pub/sub, event) | partage inter-instances, TTL moyenne (10s–10min) |
| L3 — CDN / edge (Varnish, CloudFront) | ~0 (edge) | oui | oui | très dur (purge globale lente) | réponses publiques, TTL longue, Cache-Control |
| Materialized view / DB | ~1–5ms | oui | oui | refresh planifié | agrégats coûteux, lectures analytiques |
Mental model de l'invalidation : il n'y a que deux problèmes durs en cache — le nommage et l'invalidation. Pour chaque cache, écris à la main la réponse à : « quel événement rend cette entrée fausse, et comment je la purge sur toutes les couches simultanément ? ». Si tu ne sais pas répondre, tu n'as pas un cache, tu as un bug à retardement. Le combo gagnant en prod : L1 court (2–5s) sans invalidation explicite (la TTL fait le boulot, divergence inter-pod bornée à quelques secondes) + L2 Redis invalidé par event (Kafka/Redis pub-sub qui broadcast un DEL à tous les pods).
🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
- 7 :
@nestjs/platform-fastifyv7, Fastify 3.x. Differences API notables. - 8 : Fastify 3 → 4 migration.
FastifyAdapterplus stable. - 9 :
cache-managerv4 (pas de wrapper async par défaut, breaking). Préfère@nestjs/cache-manager. - 10 :
@nestjs/cache-managerséparé.cache-managerv5 avec API entièrement Promise-based (breaking pour les anciens stores). Fastify 4.x. - 11 : Fastify 5.x officiellement supporté. Drop Node 18 → Node 20+ requis. Streaming HTTP plus performant.
@nestjs/cache-managerv3 s'appuie désormais surcache-managerv6 + Keyv : le store Redis passe par@keyv/redis(new Keyv({ store: new KeyvRedis(url) })), pluscache-manager-redis-yet. L'APIcache.get/set/delest stable ; c'est la configuration du store qui change.
Si tu migres :
- vers Nest 10 / cache-manager v5 : tous les
await cache.set(key, value, { ttl })deviennentawait cache.set(key, value, ttlMs)(ms direct, pas object). C'est le breaking le plus traître — silencieux, ton TTL devient[object Object]-ish et expire au mauvais moment. - vers Nest 11 / cache-manager v6 : remplace
redisStorepar unKeyv. Exemple Nest 11 :
// cache.module.ts — Nest 11 (cache-manager v6 + Keyv)
import { CacheModule } from '@nestjs/cache-manager';
import { createKeyv } from '@keyv/redis';
CacheModule.registerAsync({
isGlobal: true,
useFactory: () => ({
stores: [createKeyv(process.env.REDIS_URL!)],
ttl: 60_000, // ms
}),
});Compression — où, quand
@fastify/compressoucompressionmiddleware Express — gzip/brotli/deflate selon Accept-Encoding.- Coût CPU non trivial : ~5–20ms par 100KB. Sur des réponses < 1KB, ne compresse pas (overhead > gain).
- Préférable au niveau reverse proxy (nginx, Envoy, CloudFront) : libère le CPU Node, et le proxy a souvent du hardware accel.
- Brotli > gzip sur le ratio, mais plus lent à compresser. Use brotli pour les assets statiques, gzip pour les API dynamiques.
// si tu compresses dans Node, configure les seuils
await app.register(import('@fastify/compress'), {
threshold: 1024, // skip below 1KB
encodings: ['gzip', 'br'],
});🧵 CPU-bound — worker threads, clustering, offload
Tout le mental model de ce fichier dit « le bottleneck est rarement le CPU pur ». Vrai à 95%. Les 5% restants sont réels et te coûtent cher : hashing (bcrypt/argon2), parsing de gros PDF/XLSX, génération d'images, compression lourde, crypto, sérialisation de payloads massifs. Sur ces tâches, le single-thread de Node est un mur — une seule requête lourde bloque toutes les autres parce que l'event loop est mono-thread.
L'arbre de décision staff
Tâche CPU-bound (> ~50ms de calcul synchrone) ?
│
├── Non → laisse sur l'event loop, ne complique pas.
│
└── Oui → est-ce déjà offert par une lib native async ?
(argon2, sharp, libvips, zlib async…)
│
├── Oui → utilise-la. Elle libère l'event loop via le
│ libuv threadpool (UV_THREADPOOL_SIZE) sans que
│ tu gères un seul thread à la main.
│
└── Non → est-ce du JS pur lourd (parsing, transform) ?
│
├── Court & fréquent → worker_threads pool
│ (Piscina) : garde le calcul dans le process,
│ transfert mémoire zero-copy possible.
│
└── Long & rare / isolation forte → sors-le
du request path : BullMQ + worker dédié,
ou microservice. La requête HTTP rend un
202 + un job id, le client poll/SSE.| Approche | Isolation | Latence de dispatch | Partage mémoire | Quand |
|---|---|---|---|---|
| libuv threadpool (libs natives async) | thread C++, invisible | ~0 | n/a | bcrypt/argon2, sharp, zlib, fs, dns — toujours préférer |
worker_threads + Piscina | thread JS isolé | ~0.1–1ms | SharedArrayBuffer, transferList (zero-copy) | parsing JS lourd, transform CPU dans le process |
cluster / PM2 | process OS | n/a (load balancé au listen) | aucun | saturer N cœurs sur un host, isolation crash |
| BullMQ worker | process/host séparé | dispatch via Redis | aucun | tâches longues, scaling indépendant, retries |
UV_THREADPOOL_SIZE — le réglage que personne ne connaît
Les libs natives async (bcrypt, crypto.pbkdf2, fs, dns.lookup) ne tournent pas sur l'event loop : elles tournent sur le threadpool libuv, qui fait 4 threads par défaut. Sur une machine 16 cœurs qui hash 50 mots de passe en parallèle, tu en traites 4 à la fois et les 46 autres font la queue — invisible dans un flamegraph CPU classique, visible seulement en latence p99. Règle staff : UV_THREADPOOL_SIZE = nombre de cœurs (au boot, avant tout require).
// worker pool pour du parsing CSV/XLSX lourd — Piscina
// xlsx.worker.ts
import { parentPort } from 'node:worker_threads';
parentPort!.on('message', (buf: ArrayBuffer) => {
const rows = parseHeavyXlsx(Buffer.from(buf)); // ~300ms CPU pur
parentPort!.postMessage(rows);
});
// xlsx.service.ts
import Piscina from 'piscina';
@Injectable()
export class XlsxService {
private pool = new Piscina({
filename: new URL('./xlsx.worker.js', import.meta.url).pathname,
maxThreads: Math.max(1, os.cpus().length - 1), // garde 1 cœur pour l'event loop
idleTimeout: 30_000,
});
async parse(buf: Buffer): Promise<Row[]> {
// transferList = zero-copy : on transfère l'ownership du buffer,
// pas de sérialisation/clone du payload de plusieurs Mo
return this.pool.run(buf.buffer, { transferList: [buf.buffer] });
}
}Pièges de pro sur les workers :
- Le dispatch a un coût. Sous ~10–50ms de calcul, le passage de message + sérialisation coûte plus que ce que tu gagnes. Mesure avant de paralléliser.
- Sérialisation = structured clone. Passer un gros objet à un worker le copie (sauf
SharedArrayBuffer/transferList). Un payload de 50Mo cloné, c'est de l'alloc massive + GC — exactement ce que tu voulais éviter. Transfère desArrayBuffer, pas des objets riches. - Un worker qui throw non catché tue le process si tu ne gères pas
'error'/'exit'. Piscina encapsule ça ; à la main, tu dois respawn. clusterne partage pas le cache mémoire. Si tu passes de 1 process à 8 workers PM2, ton LRU L1 devient 8 LRU divergents (cf. tableau de cache). C'est souvent acceptable (TTL courte), mais documente-le.
Clustering : cluster natif vs PM2 vs K8s
clusternatif / PM2-i max: N process Node sur un host, load-balancés par l'OS sur lelisten. Sature les cœurs d'une grosse VM. Inconvénient : tout le state in-memory est dupliqué N fois, et un crash OOM peut emporter plusieurs workers.- K8s avec 1 process par pod + HPA : le pattern cloud-native moderne. Tu scales horizontalement, l'orchestrateur gère les crashs, et chaque pod est petit et jetable. Préfère ça à PM2 cluster en prod conteneurisée — tu ne veux pas deux couches d'orchestration (PM2 dans le pod + K8s dehors) qui se marchent dessus. Un process par conteneur,
enableShutdownHooks()pour un drain propre surSIGTERM.
// graceful shutdown — indispensable derrière K8s (SIGTERM avant SIGKILL)
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter);
app.enableShutdownHooks(); // Nest ferme les modules (DB pool, BullMQ, …) à SIGTERM
// Fastify draine les connexions in-flight avant de fermer le serveur📈 Observabilité — tu ne peux pas optimiser ce que tu ne mesures pas
« Never optimize without numbers » s'applique aussi en prod, pas seulement au bench. La discipline staff : instrumenter dès le jour 1 avec OpenTelemetry, raisonner avec les modèles RED (services) et USE (ressources), et alerter sur les SLO, pas sur des moyennes.
RED vs USE — les deux lentilles
| Modèle | Pour quoi | Les 3 signaux |
|---|---|---|
| RED (Rate, Errors, Duration) | endpoints/services, vue utilisateur | req/s, % erreurs, distribution de latence (p50/p95/p99) |
| USE (Utilization, Saturation, Errors) | ressources, vue système | % CPU/mémoire, event loop lag & profondeur de queue, erreurs hardware/OOM |
Le piège n°1 du junior : alerter sur la moyenne. Une latence moyenne de 80ms peut cacher un p99 à 4s qui touche tes 1% d'utilisateurs les plus précieux. Toujours raisonner en percentiles et en histogrammes, jamais en moyenne. La moyenne ment ; la p99 dit la vérité.
Instrumentation NestJS avec OpenTelemetry
// tracing.ts — chargé AVANT le bootstrap Nest (require en premier dans main)
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { HostMetrics } from '@opentelemetry/host-metrics';
export const otel = new NodeSDK({
traceExporter: new OTLPTraceExporter({ url: process.env.OTLP_URL }),
instrumentations: [
getNodeAutoInstrumentations({
// auto-instrumente http, fastify, pg/typeorm, ioredis, bullmq…
'@opentelemetry/instrumentation-fs': { enabled: false }, // trop bruyant
}),
],
});
otel.start();
new HostMetrics({ name: 'api' }).start(); // CPU, RSS, event loop lag// main.ts
import './tracing'; // <-- DOIT être le tout premier import
import { NestFactory } from '@nestjs/core';
// …Les métriques qui comptent vraiment
| Métrique | Type | Seuil d'alerte typique | Ce qu'elle révèle |
|---|---|---|---|
http_server_duration (p99) | histogram | dépasse le SLO (ex. 300ms) | latence vécue utilisateur |
nodejs_eventloop_lag_seconds (p99) | histogram | > 70ms soutenu | event loop saturé (sync I/O, GC, CPU) |
nodejs_gc_duration_seconds | histogram | pauses > 50ms fréquentes | pression GC / fuite mémoire |
process_resident_memory_bytes | gauge | croissance monotone | fuite mémoire (le signal le plus fiable) |
bullmq_queue_waiting | gauge | croissance monotone | workers sous-dimensionnés vs producteurs |
db_pool_active / db_pool_size | ratio | > 0.9 soutenu | pool DB saturé → file d'attente cachée |
Raisonnement staff sur une alerte p99 : tu reçois « p99 /api/orders à 2s ». Tu ne redéploies pas au hasard. Tu corrèles trois courbes sur la même fenêtre : (1) eventloop_lag — si lui aussi spike, c'est CPU/sync/GC, pas la DB ; (2) db_pool_active/size — si saturé, c'est un N+1 ou une requête lente qui retient le pool ; (3) une trace distribuée d'une requête lente — elle te montre exactement quel span (DB ? appel externe ? sérialisation ?) bouffe les 2s. La trace transforme « c'est lent » en « c'est ce span-là », ce qui est la différence entre deviner et savoir.
⚠️ Pitfalls
new Date()/JSON.parsedans une boucle hot — alloc massive, GC ralentit tout. Précompile/cache.- Logging synchronous (
console.log, Winston en sync) — bloque l'event loop. Pino async, ou ship via OTLP. - Pas de
keep-alivecôté client interne → handshake TCP/TLS à chaque appel. Utiliseundiciouhttp.Agent({ keepAlive: true }). - N+1 caché derrière un ORM —
order.lineslazy chargé, 500 orders = 501 queries.relations: ['lines']ou DataLoader. emitasync non await dans EventEmitter → erreurs perdues, ordre non garanti. Préfère un bus typé.- Compression activée + reverse proxy compresse aussi → CPU wasted. Choisir un seul endroit.
@nestjs/throttlermémoire sur plusieurs instances = inefficace. Utilise le storage Redis.- Memory leaks classiques : closure qui retient
req, listeners non removed,setIntervalnon clear, cache LRU sans limite.heapdump+ Chrome DevTools pour disséquer. UV_THREADPOOL_SIZEà 4 par défaut : bcrypt/argon2/crypto.pbkdf2/fsasync sérialisent au-delà de 4 en parallèle. p99 qui monte alors que le CPU paraît libre. Mets-le au nombre de cœurs, au boot avant toutrequire.- Worker thread qui clone au lieu de transférer : passer un gros objet riche à un
worker_threadsle copie (structured clone) → alloc + GC massifs. Transfère desArrayBufferviatransferList, ou utiliseSharedArrayBuffer. Sous ~10–50ms de calcul, le coût de dispatch dépasse le gain — mesure avant de paralléliser. - Double orchestration PM2-dans-le-pod + K8s : en prod conteneurisée, un process par conteneur. PM2 cluster dans un pod K8s = deux load balancers qui se marchent dessus et des métriques faussées.
🧪 Testing — load testing
# k6 example
k6 run --vus 100 --duration 60s - <<'EOF'
import http from 'k6/http';
import { check } from 'k6';
export default function () {
const res = http.get('http://localhost:3000/api/orders');
check(res, { 'status 200': r => r.status === 200, 'p95<200ms': r => r.timings.duration < 200 });
}
EOF- autocannon pour quick smoke :
autocannon -c 50 -d 30 http://localhost:3000/health. - k6 pour scenarios complexes, dashboards Grafana.
- artillery pour load + soak tests longs.
Cible à mesurer : p50, p95, p99 latency + req/s + error rate. Un p99 qui explose à charge constante = saturation event loop ou GC.
🎬 Cas d'usage concrets
Scénario 1 — API bancaire à 10K req/s
Qui : néobanque PME, API consultation soldes + transactions, pic à 10K req/s sur le créneau 8h-10h (paie des salariés, vérif des paiements). Problème : sur Express, l'API saturait à 3K req/s avec p99 à 1.2s. Le coût en pods K8s explosait. La migration Fastify + cache stratifié était sur la table depuis 6 mois.
// main.ts — Fastify + clustering via PM2
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import compress from '@fastify/compress';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ logger: false, trustProxy: true, bodyLimit: 256 * 1024 }),
);
await app.register(compress, { threshold: 4096, encodings: ['br', 'gzip'] });
await app.listen(3000, '0.0.0.0');
}
bootstrap();
// accounts.service.ts — cache stratifié L1 (LRU) + L2 (Redis)
import { LRUCache } from 'lru-cache'; // v10+ : named export, PAS default
@Injectable()
export class AccountsService {
private l1 = new LRUCache<string, AccountBalance>({ max: 5_000, ttl: 2_000 }); // 2s
constructor(@Inject(CACHE_MANAGER) private readonly l2: Cache, private readonly repo: AccountsRepository) {}
async getBalance(accountId: string): Promise<AccountBalance> {
const local = this.l1.get(accountId);
if (local) return local;
const cached = await this.l2.get<AccountBalance>(`balance:${accountId}`);
if (cached) { this.l1.set(accountId, cached); return cached; }
const fresh = await this.repo.computeBalance(accountId);
this.l1.set(accountId, fresh);
await this.l2.set(`balance:${accountId}`, fresh, 10_000); // 10s
return fresh;
}
}Gains : Fastify → +90% throughput sans changer un controller. Le L1 absorbe 70% des hits (deux secondes suffisent vu que la balance ne change que sur transaction). Le L2 partage entre instances et survit aux redémarrages. Résultat : passé à 12K req/s soutenu avec p99 à 180ms, et 60% de pods en moins.
Piège correctness banque : un cache de 2s sur un solde est acceptable pour l'affichage, mais jamais pour une décision d'autorisation de paiement. La règle staff : le cache sert le read path (UI, listing), la decision path (débit, virement) lit toujours la source de vérité dans une transaction
SERIALIZABLEou avec un verrou applicatif. Mélanger les deux = double-spend. Documente explicitement quels endpoints sont « cache-safe ».
Scénario 2 — E-commerce préparé pour Black Friday
Qui : marketplace mode française, 250K orders/jour en pic Black Friday, 30K req/s sur les pages produit. Problème : l'an passé, le site a tenu mais les images des recommandations chargeaient en 4s. La page produit faisait 50 queries DB (N+1 sur variants × tailles × stock). Cette année, objectif : p95 < 400ms même à 40K req/s.
// products.resolver.ts — DataLoader + Fastify + JSON streaming
@Resolver(() => Product)
export class ProductsResolver {
constructor(
private readonly products: ProductsService,
private readonly variantsLoader: VariantsLoader,
private readonly stockLoader: StockLoader,
) {}
@ResolveField()
variants(@Parent() product: Product) {
return this.variantsLoader.byProductId.load(product.id);
}
@ResolveField()
async stockBySize(@Parent() variant: Variant) {
return this.stockLoader.byVariantId.load(variant.id);
}
}
// stock.loader.ts
@Injectable({ scope: Scope.REQUEST })
export class StockLoader {
constructor(private readonly stock: StockRepository) {}
byVariantId = new DataLoader<string, StockBySize>(async (ids) => {
const rows = await this.stock.findByVariantIds([...ids]);
const map = new Map(rows.map(r => [r.variantId, r]));
return ids.map(id => map.get(id) ?? { variantId: id, sizes: [] });
}, { maxBatchSize: 200 });
}
// product-page.service.ts — pre-rendered JSON cached at CDN edge
@Injectable()
export class ProductPageService {
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
async render(productId: string) {
const key = `product-page:v3:${productId}`;
const cached = await this.cache.get<string>(key);
if (cached) return cached;
const fresh = await this.buildPage(productId); // expensive query
await this.cache.set(key, fresh, 60_000); // 60s
return fresh;
}
}Gains : DataLoader a fait passer 50 queries à 4 (un batch par niveau de relation). Le cache product-page à 60s a coupé 95% du trafic DB. Le CDN devant l'API stocke le JSON 30s, ce qui amortit le trafic origin. Black Friday réel : 38K req/s soutenus, p95 à 280ms, zéro incident.
Scénario 3 — Recherche immobilière p99 < 100ms
Qui : portail immobilier français, 8M annonces, recherche carte + filtres. Index Elasticsearch + cache géographique. Problème : la recherche par bounding box (filtre carte) descendait à 250ms p99 quand les filtres se cumulaient (prix + surface + DPE + chauffage). Objectif produit : p99 < 100ms sinon la carte saccade quand l'utilisateur drag.
// search.service.ts — précomputation de l'agrégat + cache géographique tiled
@Injectable()
export class SearchService {
constructor(
private readonly es: ElasticsearchService,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
) {}
async searchByBbox(bbox: BoundingBox, filters: Filters) {
// Snap bbox to grid (precision 0.01°) for cache key
const tile = snapToTile(bbox, 0.01);
const key = `search:${tile}:${hashFilters(filters)}`;
const cached = await this.cache.get<SearchResult>(key);
if (cached) return cached;
const query = buildEsQuery(bbox, filters);
const result = await this.es.search({
index: 'listings',
body: query,
preference: '_local', // route to local shard
timeout: '80ms', // hard cap
});
const normalized = normalize(result);
await this.cache.set(key, normalized, 30_000); // 30s tile cache
return normalized;
}
}
// runtime monitoring
import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
const p99 = h.percentile(99) / 1e6;
if (p99 > 50) logger.warn({ p99 }, 'event loop lag spiking');
h.reset();
}, 5_000);Gains : le tiling cache absorbe 80% des drags (l'utilisateur revient souvent sur les mêmes tuiles). Le timeout: '80ms' côté ES évite les requêtes qui partent en spinning. Résultat : p99 à 65ms sur la recherche carte, drag fluide à 60fps côté client.
🛠️ Exemple end-to-end
Mise en situation : tu es responsable d'une API d'inventaire warehouse (logistique e-commerce). 500 entrepôts, 4M SKU, 2K req/s sur l'API de stock en pic. Tu veux : Fastify + cache Redis + DataLoader + bench autocannon + détection automatique de regression perf en CI.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import compress from '@fastify/compress';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
logger: false,
trustProxy: true,
bodyLimit: 128 * 1024,
maxParamLength: 200,
}),
);
await app.register(compress, { threshold: 2048, encodings: ['br', 'gzip'] });
app.enableShutdownHooks();
await app.listen(3000, '0.0.0.0');
}
bootstrap();// src/inventory/inventory.module.ts
import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import { redisStore } from 'cache-manager-redis-yet';
import { InventoryController } from './inventory.controller';
import { InventoryService } from './inventory.service';
import { StockLoader } from './stock.loader';
import { WarehouseLoader } from './warehouse.loader';
@Module({
imports: [
CacheModule.registerAsync({
isGlobal: true,
useFactory: async () => ({
store: await redisStore({ url: process.env.REDIS_URL!, ttl: 30_000 }),
}),
}),
],
controllers: [InventoryController],
providers: [InventoryService, StockLoader, WarehouseLoader],
})
export class InventoryModule {}// src/inventory/inventory.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { LRUCache } from 'lru-cache'; // v10+ : named export
import { StockLoader } from './stock.loader';
import { WarehouseLoader } from './warehouse.loader';
import { StockRepository } from './stock.repository';
interface StockSnapshot {
sku: string;
available: number;
reserved: number;
warehouses: Array<{ id: string; available: number }>;
asOf: string;
}
@Injectable()
export class InventoryService {
// L1: 30s in-process LRU for hottest SKUs
private readonly l1 = new LRUCache<string, StockSnapshot>({ max: 50_000, ttl: 30_000 });
constructor(
@Inject(CACHE_MANAGER) private readonly l2: Cache,
private readonly stockLoader: StockLoader,
private readonly warehouseLoader: WarehouseLoader,
private readonly repo: StockRepository,
) {}
async getSnapshot(sku: string): Promise<StockSnapshot> {
const local = this.l1.get(sku);
if (local) return local;
const cached = await this.l2.get<StockSnapshot>(`stock:${sku}`);
if (cached) { this.l1.set(sku, cached); return cached; }
const snapshot = await this.computeFresh(sku);
this.l1.set(sku, snapshot);
await this.l2.set(`stock:${sku}`, snapshot, 60_000); // 60s
return snapshot;
}
async getBatch(skus: string[]): Promise<StockSnapshot[]> {
// Batch via DataLoader to avoid N+1 from a single request
return Promise.all(skus.map(sku => this.stockLoader.bySku.load(sku)));
}
private async computeFresh(sku: string): Promise<StockSnapshot> {
const lines = await this.repo.findStockLines(sku); // single grouped query
const warehouses = await this.warehouseLoader.byIds.loadMany(lines.map(l => l.warehouseId));
const available = lines.reduce((s, l) => s + l.available, 0);
const reserved = lines.reduce((s, l) => s + l.reserved, 0);
return {
sku,
available,
reserved,
warehouses: lines.map((l, i) => ({ id: l.warehouseId, available: l.available })),
asOf: new Date().toISOString(),
};
}
// Invalidation: called by event handlers when stock moves
async invalidate(sku: string) {
this.l1.delete(sku);
await this.l2.del(`stock:${sku}`);
}
}// src/inventory/stock.loader.ts
import { Injectable, Scope } from '@nestjs/common';
import DataLoader from 'dataloader';
import { StockRepository } from './stock.repository';
@Injectable({ scope: Scope.REQUEST })
export class StockLoader {
constructor(private readonly repo: StockRepository) {}
bySku = new DataLoader<string, StockSnapshot>(async (skus) => {
const rows = await this.repo.findStockLinesForSkus([...skus]);
const grouped = new Map<string, StockLine[]>();
for (const r of rows) {
if (!grouped.has(r.sku)) grouped.set(r.sku, []);
grouped.get(r.sku)!.push(r);
}
return skus.map(sku => buildSnapshot(sku, grouped.get(sku) ?? []));
}, { maxBatchSize: 500, cache: false /* request-scoped already */ });
}// src/inventory/inventory.controller.ts
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { InventoryService } from './inventory.service';
@Controller('inventory')
export class InventoryController {
constructor(private readonly inventory: InventoryService) {}
@Get(':sku')
one(@Param('sku') sku: string) {
return this.inventory.getSnapshot(sku);
}
@Post('batch')
batch(@Body() body: { skus: string[] }) {
return this.inventory.getBatch(body.skus);
}
}# .github/workflows/perf.yml
jobs:
perf-bench:
runs-on: ubuntu-latest
services:
redis: { image: redis:7-alpine, ports: ['6379:6379'] }
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile && pnpm build
- run: node dist/main.js &
- run: npx wait-on http://localhost:3000/health
- name: Autocannon
run: |
npx autocannon -c 100 -d 30 --json http://localhost:3000/inventory/SKU-001 > perf.json
node scripts/check-perf.js perf.json// scripts/check-perf.js — fail CI if p99 > 50ms or rps < 5000
const fs = require('node:fs');
const r = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const p99 = r.latency.p99;
const rps = r.requests.average;
if (p99 > 50 || rps < 5000) {
console.error(`Perf regression: p99=${p99}ms rps=${rps}`);
process.exit(1);
}
console.log(`OK p99=${p99}ms rps=${rps}`);Effets concrets : sur la même machine, l'API atteint 7 800 req/s soutenu avec p99 à 38ms (vs 2 200 req/s p99 à 180ms en version Express sans cache). Le job perf-bench en CI casse une PR dès qu'une regression > 10% apparaît. L'invalidation L1+L2 sur événement stock (consommé d'une queue Kafka) garde les caches frais à <100ms de retard sur la réalité.
🤖 Servir un agent IA depuis NestJS — la perf change de nature
Quand ton endpoint appelle un LLM (Anthropic Claude : claude-opus-4-8 flagship, claude-sonnet-4-6 équilibré, claude-haiku-4-5 rapide/cheap), toutes les hypothèses perf de ce fichier s'inversent. Une requête « classique » dure 5–50ms ; une génération LLM dure 2–60 secondes et streame des tokens. Tu ne dimensionnes plus pour le CPU ou le throughput, mais pour des connexions longues, concurrentes, annulables.
Mental model — l'inversion
| Dimension | API classique | API qui sert du LLM |
|---|---|---|
| Durée requête | ms | secondes → minutes |
| Bottleneck | CPU / DB / event loop | latence réseau du provider + tokens/s |
| Unité de coût | CPU-ms | tokens (input + output, $ réel) |
| Concurrence | milliers de req courtes | centaines de connexions longues ouvertes |
| Annulation | rare | première classe (user ferme l'onglet = brûle des $) |
| Pattern réseau | request/response | streaming (SSE / WebSocket) |
Conséquence staff : le risque n'est plus « event loop bloqué » mais « connexions zombies » — des streams que personne n'écoute mais qui continuent de consommer le provider et de facturer. La discipline numéro un : propager l'annulation client jusqu'au provider via AbortController.
Client LLM injecté par DI (jamais new Anthropic() dans un champ)
new Anthropic() dans un service, c'est : pas de mock en test, pas de config centralisée, pas de pool de keep-alive partagé, clé API en dur. On le câble en forRootAsync.
// llm/llm.module.ts
import { Module, DynamicModule } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
export const ANTHROPIC = Symbol('ANTHROPIC');
@Module({})
export class LlmModule {
static forRootAsync(): DynamicModule {
return {
module: LlmModule,
global: true,
providers: [
{
provide: ANTHROPIC,
useFactory: () =>
new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY!,
maxRetries: 3, // le SDK retry les 429/5xx avec backoff exponentiel
timeout: 60_000, // hard cap par requête
}),
},
],
exports: [ANTHROPIC],
};
}
}Streaming des tokens en SSE + annulation bout-en-bout
Le point critique perf/coût : quand le client se déconnecte (req.raw.on('close')), on abort le stream provider. Sans ça, la génération continue côté Anthropic et tu paies les tokens dans le vide.
// chat/chat.controller.ts (FastifyAdapter)
import { Controller, Post, Body, Req, Res } from '@nestjs/common';
import type { FastifyRequest, FastifyReply } from 'fastify';
import { Inject } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from '../llm/llm.module';
@Controller('chat')
export class ChatController {
constructor(@Inject(ANTHROPIC) private readonly anthropic: Anthropic) {}
@Post('stream')
async stream(
@Body() body: { messages: Anthropic.MessageParam[] },
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
) {
res.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
// Fastify-derrière-nginx : désactive le buffering proxy
'X-Accel-Buffering': 'no',
});
// Annulation bout-en-bout : client close → abort provider
const ac = new AbortController();
req.raw.on('close', () => ac.abort());
try {
const stream = await this.anthropic.messages.stream(
{
model: 'claude-sonnet-4-6',
max_tokens: 1024,
messages: body.messages,
},
{ signal: ac.signal }, // <-- propagation de l'annulation
);
for await (const event of stream) {
if (ac.signal.aborted) break;
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
res.raw.write(`event: token\ndata: ${JSON.stringify(event.delta.text)}\n\n`);
}
}
const final = await stream.finalMessage();
res.raw.write(`event: done\ndata: ${JSON.stringify({ usage: final.usage })}\n\n`);
} catch (err) {
if (!ac.signal.aborted) {
res.raw.write(`event: error\ndata: ${JSON.stringify({ message: 'stream failed' })}\n\n`);
}
} finally {
res.raw.end();
}
}
}Détails qui font la différence en prod :
X-Accel-Buffering: no+Cache-Control: no-transform: sinon nginx/Cloudflare bufferise ton SSE et l'utilisateur voit la réponse arriver d'un bloc après 30s au lieu de token par token.- Pas de
@nestjs/compresssur cet endpoint : la compression bufferise par nature → tue le streaming. Exclus la route SSE de la compression (shouldCompressqui retournefalsesurtext/event-stream). - Heartbeat : envoie un
: ping\n\ntoutes les 15s si aucun token, sinon les load balancers (ALB idle timeout 60s) coupent la connexion pendant untool_uselent. max_tokensest un budget coût : c'est un plafond dur de dépense par requête. Mets-le bas par défaut, augmente-le pour les endpoints premium.- Backpressure : si tu fais
res.raw.write()en boucle sans regarder sa valeur de retour, et que le client est lent (mobile sur 3G), tu bufferises en RAM côté serveur sans limite — un client lent peut faire gonfler ta heap.write()retournefalsequand le buffer du socket est plein : respecte-le, attends l'event'drain'. Le SDK Anthropic produit les tokens plus vite qu'un mobile ne les consomme, donc ce n'est pas théorique.
// respecter le backpressure : ne pas noyer un client lent
async function safeWrite(res: FastifyReply, chunk: string): Promise<void> {
if (!res.raw.write(chunk)) {
await new Promise<void>((resolve) => res.raw.once('drain', resolve));
}
}
// dans la boucle : await safeWrite(res, `event: token\ndata: ${...}\n\n`);- Prompt caching = le plus gros levier coût/latence sur un LLM. Anthropic facture les cache reads à ~0.1× du prix input. Si tu réinjectes le même gros system prompt / contexte RAG à chaque requête, marque le préfixe stable avec
cache_control: { type: 'ephemeral' }: tu paies l'écriture une fois (~1.25×), puis ~0.1× sur chaque hit. Invariant : le cache est un prefix match — la moindre variation d'un octet en amont (unDate.now(), un id de requête dans le system prompt) invalide tout ce qui suit. Garde le contenu stable avant le contenu volatile. Vérifieusage.cache_read_input_tokens > 0; s'il est à zéro sur des requêtes au préfixe identique, un invalidateur silencieux est à l'œuvre.
La boucle agentique (tool use) côté serveur
L'agent appelle un outil → tu l'exécutes → tu renvoies le résultat au modèle → il continue. Chaque tour est un aller-retour réseau de plusieurs secondes. La perf ici = paralléliser les tool calls d'un même tour et borner le nombre de tours (un agent en boucle infinie = facture infinie).
// agent/agent.service.ts
async runAgentLoop(messages: Anthropic.MessageParam[], signal: AbortSignal) {
const MAX_TURNS = 8; // garde-fou anti-boucle infinie / coût
for (let turn = 0; turn < MAX_TURNS; turn++) {
const resp = await this.anthropic.messages.create(
{ model: 'claude-sonnet-4-6', max_tokens: 2048, tools: this.tools, messages },
{ signal },
);
messages.push({ role: 'assistant', content: resp.content });
if (resp.stop_reason !== 'tool_use') return resp; // terminé
const toolUses = resp.content.filter((b) => b.type === 'tool_use');
// Parallélise les outils d'un même tour (ils sont indépendants)
const results = await Promise.all(
toolUses.map(async (tu) => ({
type: 'tool_result' as const,
tool_use_id: tu.id,
content: await this.dispatchTool(tu.name, tu.input, signal),
})),
);
messages.push({ role: 'user', content: results });
}
throw new Error('agent exceeded MAX_TURNS'); // observabilité : alerte si ça arrive souvent
}Jobs IA en BullMQ — idempotence, coût, output partiel
Pour les générations longues (rapport, batch d'embeddings), tu sors du request/response et tu pousses dans une queue. Trois invariants staff :
- Idempotence keyée sur une
generationId(pas un autoincrement queue) — sinon un retry BullMQ relance une génération à $ et double-écrit. - Retry cost-aware : retry les
429/5xx/timeout (transitoires), jamais les400/invalid_request(déterministes → boucle de retry qui brûle du budget pour rien). - Output partiel persisté : si le worker crash à 80% d'un long stream, persiste l'avancement par chunk pour reprendre, pas pour recommencer.
// ai-jobs/generation.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job, UnrecoverableError } from 'bullmq';
@Processor('ai-generation')
export class GenerationProcessor extends WorkerHost {
async process(job: Job<{ generationId: string; prompt: string }>) {
const { generationId, prompt } = job.data;
// 1. Idempotence : déjà fait ? on ne re-paie pas le LLM
const existing = await this.store.find(generationId);
if (existing?.status === 'done') return existing.result;
const ac = new AbortController();
try {
const stream = await this.anthropic.messages.stream(
{ model: 'claude-opus-4-8', max_tokens: 4096, messages: [{ role: 'user', content: prompt }] },
{ signal: ac.signal },
);
let acc = '';
for await (const ev of stream) {
if (ev.type === 'content_block_delta' && ev.delta.type === 'text_delta') {
acc += ev.delta.text;
// 3. Output partiel : checkpoint périodique pour reprise
if (acc.length % 2000 < 50) await this.store.savePartial(generationId, acc);
}
}
const final = await stream.finalMessage();
await this.store.complete(generationId, acc, final.usage); // log $ = usage.input/output_tokens
return acc;
} catch (err: any) {
// 2. Retry cost-aware
if (err.status === 400 || err.status === 422) {
throw new UnrecoverableError(`bad request, no retry: ${err.message}`); // BullMQ ne retry pas
}
throw err; // 429/5xx/timeout → retry avec backoff (config attempts + backoff exponentiel)
}
}
}À l'edge : idempotence, rate-limit, cost-guard
Avant même de toucher le LLM, un guard NestJS doit : (a) rate-limiter par user (un LLM endpoint est trivial à abuser — @nestjs/throttler avec storage Redis, pas mémoire, cf. pitfall #7), (b) cost-guard : refuser si l'utilisateur a dépassé son quota de tokens du jour (compteur Redis incrémenté depuis usage), (c) idempotency-key : header Idempotency-Key → si déjà vu, renvoie le résultat caché au lieu de relancer une génération. Ces trois protections transforment un endpoint « qui peut ruiner la boîte » en endpoint borné.
Anti-pattern observé en revue : exposer un endpoint LLM streaming sans abort sur disconnect, sans
max_tokensplafonné, sans rate-limit Redis. À 50 users malveillants ouvrant et fermant des onglets, la facture Anthropic part en vrille pendant que tes connexions zombies saturent le pool. Les trois garde-fous (abort, plafond tokens, throttle Redis) ne sont pas optionnels.
🔁 Quand utiliser / éviter
- Fastify : par défaut sauf si dépendance hard à un middleware Express (passport-XXX exotique).
- Cache : sur les reads chers ou fréquents, jamais sur les writes. Invalidation > caching — pense d'abord à comment tu invalides.
- DataLoader : GraphQL toujours ; REST seulement si tu as un endpoint qui fan-out (ex.
/users?ids=...). - Profiling : avant chaque optim non triviale. Sinon tu optimises ce qui n'est pas le bottleneck.
- Évite la microoptimisation sans bench.
forvsforEach,??vs||: c'est du bruit. Architecture (N+1, sync I/O) bat tout ça d'un facteur 10–100. - Évite le cache "à tout prix" — si la TTL est < 1s, le coût d'invalidation l'emporte. Préfère résoudre le bottleneck en amont (index DB, requête plus efficace).
Common slow paths — checklist
Quand un endpoint est lent, parcours dans cet ordre :
- Database —
EXPLAIN ANALYZEla requête. Index manquant ? Scan séquentiel sur 1M lignes ?pg_stat_statementsmontre les requêtes les plus coûteuses. - N+1 — log toutes les queries pendant une requête en dev. > 10 queries pour un endpoint REST = suspect.
- External APIs — un appel Stripe à 800ms = ton endpoint à 800ms minimum. Cache si idempotent, ou async (queue).
- JSON parsing / sérialisation — sur de gros payloads (> 1MB),
fast-json-stringifyou stream NDJSON. - Crypto sync —
bcrypt.compareSyncbloque l'event loop. Toujoursawait bcrypt.compare(...). - Logging excessif —
logger.debug({ huge: req.body })à chaque appel = sérialisation coûteuse même si filtrée. Pino skip si level above. - GC pressure — heap qui yo-yo entre 100MB et 500MB. Réduis les allocations (réutilise buffers, évite
JSON.parserépétés sur le même string).
Memory leaks — diagnostic
# capture heap snapshot in prod (via signal SIGUSR2 with --inspect)
kill -SIGUSR2 <pid>
# or programmatically
node --inspect dist/main.js
# Chrome DevTools > Memory > Take heap snapshotSuspects classiques en Nest :
- Caches sans limite —
Mapqui grandit indéfiniment, jamais purgé. Uselru-cacheavecmax. - Listeners non removed —
emitter.on('x', fn)dans un serviceREQUESTscoped, sansoffau destroy. - Closure qui retient
req— passage dereqdans un setTimeout long → tout l'objet request reste en mémoire. - TypeORM connection pool sans limite —
extra: { max: 100 }par défaut peut être trop.
Outil : clinic heapprofiler ou heapdump + heapsnapshot analyse comparative entre T0 et T0+10min sous charge constante. Les classes qui grossissent = candidats.
🏋️ Exercices
Progressifs : implémente → rends-le production-grade → casse-le puis répare. Fais-les dans l'ordre, garde tes chiffres autocannon avant/après pour chaque.
Exercice 1 — Migrer Express → Fastify et le prouver
Objectif : migrer une app Nest existante sur FastifyAdapter et mesurer le gain réel, pas le gain marketing. Indice/Solution : NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter()). Bench identique avant/après (autocannon -c 100 -d 30). Attends-toi à +30–80% sur un endpoint trivial, proche de 0% sur un endpoint DB-bound (le bottleneck est la DB, pas le routing). La leçon : Fastify aide où le HTTP est le coût, pas ailleurs. Note aussi ce qui casse (middlewares Express, @Res() typé).
Exercice 2 — Cache stratifié L1+L2 avec invalidation par event
Objectif : implémenter le combo lru-cache (L1, 3s) + Redis (L2, 60s) et invalider les deux couches sur tous les pods quand la donnée change. Indice/Solution : L1 par instance, L2 partagé. Pour invalider L1 sur tous les pods : Redis pub/sub — un pod publie invalidate:sku:123, tous s'abonnent et font l1.delete(). Mesure le hit-ratio par couche (compteurs). Piège : sans pub/sub, le pod A invalide son L1 mais le pod B sert du périmé pendant 3s → divergence inter-pod. Décide si c'est acceptable (souvent oui pour 3s).
Exercice 3 — Tuer un N+1 avec DataLoader request-scoped
Objectif : prendre un endpoint qui fait 1 + N queries et le ramener à 2 queries via DataLoader. Indice/Solution : @Injectable({ scope: Scope.REQUEST }) (un loader frais par requête, sinon tu caches entre users). Active le logging des queries TypeORM/Prisma, compte-les avant (501) et après (2). Vérifie que la fonction de batch préserve l'ordre (ids.map(id => map.get(id) ?? null)) — un mismatch d'ordre est le bug DataLoader classique : les mauvaises données reviennent silencieusement.
Exercice 4 — Endpoint LLM streaming production-grade
Objectif : exposer un /chat/stream SSE qui streame des tokens Claude, s'annule à la déconnexion client, et plafonne le coût. Indice/Solution : anthropic.messages.stream(..., { signal: ac.signal }) + req.raw.on('close', () => ac.abort()). Vérifie l'annulation : ouvre le stream, ferme l'onglet, confirme dans les logs Anthropic (ou via un console.log dans le for await) que la boucle s'arrête. Ajoute max_tokens bas, exclus la route de la compression, heartbeat 15s. Test de charge : 200 connexions SSE concurrentes — observe la RAM et le nombre de FDs ouverts.
Exercice 5 (casser puis réparer) — La fuite mémoire du closure req
Objectif : provoquer une fuite mémoire réaliste, la diagnostiquer au heap snapshot, la réparer. Indice/Solution : crée un handler qui fait setTimeout(() => log(req.body), 60_000) (le closure retient tout l'objet req, headers + body, 60s). Charge avec autocannon -c 200, capture deux heap snapshots à 1min d'intervalle (Chrome DevTools > Memory > comparison), repère les IncomingMessage/objets req qui s'accumulent. Répare en extrayant uniquement le champ utile (const body = req.body; setTimeout(() => log(body))). Re-mesure : la courbe heap se stabilise.
Exercice 6 (casser puis réparer) — Connexions LLM zombies
Objectif : reproduire l'explosion de coût/connexions d'un endpoint LLM sans abort, puis la corriger. Indice/Solution : déploie l'endpoint de l'exo 4 sans ac.abort() sur close. Script : ouvre 50 streams, ferme-les immédiatement côté client. Observe que les générations continuent serveur-side (logs usage), connexions/FDs qui montent, facturation tokens dans le vide. Répare avec l'AbortController propagé au SDK. La leçon staff : sur un endpoint LLM, l'annulation est une feature de coût, pas un détail UX.
Exercice 7 (casser puis réparer) — L'endpoint CPU-bound qui gèle tout
Objectif : prouver qu'une tâche CPU synchrone bloque l'event loop pour tous les clients, puis l'offloader sans bloquer. Indice/Solution : crée un GET /report qui fait un calcul synchrone de ~300ms (parse XLSX lourd, ou une boucle Fibonacci naïve). En parallèle, garde un GET /health trivial. Charge /report avec autocannon -c 20 et mesure la latence de /health pendant ce temps : tu verras /health passer de 2ms à des centaines de ms — l'event loop est gelé par /report. Vérifie nodejs_eventloop_lag_seconds qui explose. Répare en sortant le calcul dans un worker pool (Piscina, maxThreads = cœurs − 1), avec transferList pour transférer le buffer en zero-copy. Re-mesure : /health reste plat sous charge, le lag event loop redescend. Bonus break : passe un gros objet riche au worker au lieu d'un ArrayBuffer transféré, et observe le coût d'alloc/GC du structured clone — la leçon : un worker mal câblé peut être plus lent que pas de worker.
🎤 En entretien
Q : « Une API Node tient 3K req/s et sature, p99 à 1.2s. Par où tu commences ? » R : Je ne touche à rien avant de profiler. clinic doctor pour classer le bottleneck (CPU vs I/O vs event loop), et je regarde nodejs_eventloop_lag_seconds — 99% du temps c'est du lag event loop (sync I/O, crypto sync, logging sync, GC pressure) ou un N+1, pas du CPU pur. J'optimise le bottleneck mesuré, pas mon intuition. Fastify et cache viennent après le diagnostic.
Q : « Quelle est la vraie difficulté du caching ? » R : Pas le get/set — c'est le nommage et l'invalidation. Pour chaque cache je dois pouvoir écrire : quel événement le rend faux, et comment je purge sur toutes les couches (L1 in-process sur chaque pod, L2 Redis, L3 CDN) simultanément. L1 sans canal d'invalidation diverge entre pods ; la TTL courte borne le risque. Si je ne sais pas invalider, je ne mets pas de cache — je règle le bottleneck en amont (index, requête).
Q : « Pourquoi un DataLoader doit-il être request-scoped, et que se passe-t-il s'il ne l'est pas ? » R : Le DataLoader mémoïse par défaut. En singleton, il cacherait des résultats entre utilisateurs et entre requêtes → un user voit les données d'un autre, et les données ne sont jamais fraîches. Scope.REQUEST garantit un loader vierge par requête : il dédoublonne dans la requête (résout le N+1) et meurt avec elle. Le piège secondaire : un singleton accidentel ne « plante » pas, il sert silencieusement du périmé/cross-tenant.
Q : « Tu exposes un endpoint qui streame un LLM. Quels sont les pièges perf et coût ? » R : L'unité de coût devient le token, pas le CPU-ms, et les requêtes durent des secondes → des connexions longues concurrentes. Le piège n°1 : pas d'abort sur déconnexion client → connexions zombies qui continuent de facturer le provider. Donc : AbortController propagé au SDK + req.on('close'), max_tokens comme plafond de dépense, rate-limit Redis par user, exclure la route de la compression (qui bufferise et tue le streaming), heartbeat pour survivre aux idle timeouts des load balancers, et respecter le backpressure du socket (un client lent ne doit pas faire gonfler ta heap). Côté coût pur : prompt caching sur le préfixe stable (system + contexte RAG) → cache reads à ~0.1× l'input. En jobs : idempotence keyée sur generationId et retry uniquement les erreurs transitoires (429/5xx), jamais les 400.
Q : « Un endpoint de hash de mot de passe (bcrypt) écroule ta latence sous charge alors que ton CPU n'est qu'à 30%. Pourquoi ? » R : bcrypt async ne tourne pas sur l'event loop mais sur le threadpool libuv, qui fait 4 threads par défaut. À 50 hash concurrents, tu en traites 4 à la fois et les 46 autres font la queue — d'où la latence p99 qui explose pendant que le CPU global paraît libre (les threads attendent leur tour, ils ne brûlent pas du cycle). Le fix : UV_THREADPOOL_SIZE = nombre de cœurs, réglé au boot avant tout require. Plus largement, c'est l'illustration que « CPU à 30% » est un signal trompeur : la saturation (profondeur de queue) compte plus que l'utilisation — c'est exactement la distinction du modèle USE.
Q : « Alerte : p99 d'un endpoint à 2s. Décris ta démarche, pas ta solution. » R : Je ne touche pas au code avant de corréler. Trois courbes sur la même fenêtre : eventloop_lag_p99 (si lui spike → CPU/sync/GC, pas la DB), db_pool_active/size (si saturé → requête lente ou N+1 qui retient le pool), et une trace distribuée d'une requête lente qui m'isole le span coupable (DB ? appel externe ? sérialisation ?). Je raisonne en percentiles, jamais en moyenne — une moyenne à 80ms peut cacher une p99 à 4s. La trace transforme « c'est lent » en « c'est ce span-là » : c'est la différence entre deviner et savoir. Ensuite seulement je profile localement (clinic) et j'optimise le bottleneck mesuré.
🔗 Liens
- Fastify benchmarks
- Clinic.js
- autocannon / k6
- DataLoader
- Node.js Performance docs
- Node.js Diagnostic Tools
- Article : "Don't Block the Event Loop" — Node.js docs
- Piscina — worker thread pool
- OpenTelemetry JS — auto-instrumentations
- Anthropic — prompt caching
- Google SRE Book — chapitres RED/USE & SLO/alerting