Skip to content

HTTP server natif, HTTP/2, HTTP/3 et clients modernes en Node.js

TL;DR — Le module http de Node.js reste la base de tout serveur (Express, Fastify, Next.js l'utilisent en dessous). En production, on ajuste systématiquement les timeouts (headersTimeout, requestTimeout, keepAliveTimeout) pour résister aux attaques slowloris, on choisit consciemment entre HTTP/1.1 keep-alive, HTTP/2 multiplexé et HTTP/3 (encore expérimental sous Node via des packages externes). Côté client, undici est le moteur derrière fetch natif et offre les meilleures performances grâce à son pool de connexions. Le choix du framework (Fastify ~50k req/s, Express ~15k req/s, uWebSockets.js ~150k req/s) est moins critique que la correcte configuration TLS, le pooling et la gestion du back-pressure.

🧠 Mental model — ASCII + analogie

Imagine un standard téléphonique d'hôtel des années 1990 vs un central téléphonique moderne. HTTP/1.1 sans keep-alive, c'est une ligne par appel : tu raccroches après chaque conversation. HTTP/1.1 avec keep-alive, c'est garder la ligne ouverte pour plusieurs appels successifs, mais un seul appel à la fois. HTTP/2, c'est le central moderne : une seule ligne physique, mais des dizaines de conversations en parallèle (multiplexing), avec priorités et compression d'en-têtes (HPACK). HTTP/3 va plus loin : il remplace TCP par QUIC sur UDP, ce qui supprime le head-of-line blocking au niveau transport.

                       ┌─────────────────────────────────────────┐
                       │              Node.js process            │
                       │                                         │
   TCP socket (libuv)  │   ┌───────────────────────────────┐     │
   ──────────────────▶ │   │  http.Server (EventEmitter)   │     │
                       │   │   ├── 'connection' (Socket)   │     │
                       │   │   ├── 'request' (req,res)     │     │
                       │   │   ├── 'clientError'           │     │
                       │   │   └── timeouts (3 niveaux)    │     │
                       │   └───────────────────────────────┘     │
                       │              │                          │
                       │              ▼                          │
                       │   ┌───────────────────────────────┐     │
                       │   │  IncomingMessage (Readable)   │     │
                       │   │  ServerResponse (Writable)    │     │
                       │   └───────────────────────────────┘     │
                       └─────────────────────────────────────────┘

   HTTP/1.1   ─────[req1]──[res1]──[req2]──[res2]────▶  (séquentiel par connexion)

   HTTP/2     ─────[req1.frame|req2.frame|req3.frame]─▶ (streams multiplexés)
              ◀────[res1.frame|res2.frame|res3.frame]──

   HTTP/3     ─────QUIC streams sur UDP, 0-RTT, pas de HoL blocking TCP─▶

Mentalement, un serveur Node.js est un EventEmitter au-dessus de net.Server. Chaque connection produit un socket TCP ; chaque socket peut transporter une ou plusieurs requêtes selon le protocole. Le parsing HTTP est délégué à llhttp (réécriture en C de http_parser, beaucoup plus rapide et auditable). Le serveur émet request lorsqu'une requête complète est parsée, et c'est à toi de produire une réponse.

🛠️ Code minimal (ts/js)

ts
// server-http1.ts — HTTP/1.1 natif avec timeouts durcis
import { createServer, IncomingMessage, ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";

const server = createServer((req: IncomingMessage, res: ServerResponse) => {
  const requestId = randomUUID();
  res.setHeader("x-request-id", requestId);
  res.setHeader("content-type", "application/json");

  if (req.method === "GET" && req.url === "/health") {
    res.statusCode = 200;
    res.end(JSON.stringify({ status: "ok", requestId }));
    return;
  }

  res.statusCode = 404;
  res.end(JSON.stringify({ error: "not_found", requestId }));
});

// Durcissement contre slowloris et clients défaillants
server.headersTimeout = 60_000;        // Temps max pour recevoir les en-têtes
server.requestTimeout = 300_000;       // Temps max pour recevoir le corps complet
server.keepAliveTimeout = 5_000;       // Temps d'inactivité avant fermeture keep-alive
server.maxHeadersCount = 100;          // Protection contre header bombing
server.timeout = 0;                    // Désactive l'ancien timeout global

server.on("clientError", (err, socket) => {
  // err.code peut être HPE_HEADER_OVERFLOW, ECONNRESET, etc.
  if (socket.writable) {
    socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
  } else {
    socket.destroy(err);
  }
});

server.listen(3000, () => console.log("HTTP/1.1 listening on :3000"));
ts
// server-http2.ts — HTTP/2 sécurisé avec ALPN
import { createSecureServer } from "node:http2";
import { readFileSync } from "node:fs";

const server = createSecureServer({
  key: readFileSync("./key.pem"),
  cert: readFileSync("./cert.pem"),
  // ALPN négocie h2 (HTTP/2) ou http/1.1 selon le client
  allowHTTP1: true,
  settings: {
    maxConcurrentStreams: 100,
    initialWindowSize: 1 << 20, // 1 MiB par stream
  },
});

server.on("stream", (stream, headers) => {
  const path = headers[":path"];
  const method = headers[":method"];

  stream.respond({
    ":status": 200,
    "content-type": "application/json",
    // Headers HTTP/2 doivent être en minuscules
  });
  stream.end(JSON.stringify({ method, path }));
});

server.on("sessionError", (err) => console.error("session error:", err));
server.listen(8443);
ts
// client-undici.ts — Client HTTP moderne avec pooling
import { Agent, fetch, request } from "undici";

const agent = new Agent({
  connections: 100,            // Connexions par origine
  pipelining: 1,               // HTTP/1.1 pipelining (1 = désactivé, recommandé)
  keepAliveTimeout: 30_000,
  keepAliveMaxTimeout: 600_000,
  bodyTimeout: 30_000,         // Timeout réception body
  headersTimeout: 30_000,      // Timeout réception headers
});

// Avec fetch (API Web standard)
const r1 = await fetch("https://api.example.com/v1/users", {
  // @ts-expect-error — dispatcher n'est pas dans le type DOM
  dispatcher: agent,
  signal: AbortSignal.timeout(5_000),
});

// Avec undici.request (plus bas niveau, plus rapide)
const { statusCode, headers, body } = await request(
  "https://api.example.com/v1/users",
  { dispatcher: agent, method: "GET" }
);
const data = await body.json();
bash
# Génération de certificats auto-signés pour HTTP/2 en local
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \
  -days 365 -nodes -subj "/CN=localhost"

🎯 Patterns courants

Architecture en couches : socket → parsing → routing → handler. Au niveau le plus bas, tu as net.Server (TCP). Le module http ajoute par-dessus llhttp pour parser HTTP/1.1 et exposer req/res. Les frameworks (Express, Fastify) ajoutent encore : routeur (matching de pattern → handler), middlewares (chaîne de fonctions), validation, sérialisation. Comprendre cette pile t'aide à diagnostiquer : si tu vois une fuite mémoire, est-elle au niveau socket (sockets non fermés), parsing (buffers non libérés), routing (closure qui retient un gros objet), ou handler (state global qui grossit) ?

Reverse proxy local avec keep-alive. Lorsque ton service Node parle à un autre service interne, garder un Agent global avec keepAlive: true économise 1-3 ms par requête (handshake TCP + TLS évité). Sans cela, sous forte charge, tu épuises les ports éphémères de l'OS et observes des EADDRNOTAVAIL.

Streaming response. Pour servir un gros JSON, du CSV ou du NDJSON, n'accumule pas en mémoire. Pipe directement depuis ta source (base de données, S3, fichier) vers res. Avec fs.createReadStream(path).pipe(res), tu obtiens automatiquement le back-pressure : si le client est lent, la lecture du fichier ralentit.

Trailers. Les trailers HTTP/1.1 (en-têtes envoyés après le corps) sont utiles pour signaler un checksum ou un statut calculé pendant le streaming. Tu déclares Trailer: X-Checksum dans les headers, puis tu appelles res.addTrailers({ "x-checksum": "..." }) juste avant res.end().

Graceful shutdown. À la réception de SIGTERM, ne tue pas brutalement le process. Appelle server.close() (qui arrête d'accepter de nouvelles connexions, mais laisse finir les en cours), puis ferme tes pools (DB, Redis). Définis un délai max (30 s) avec setTimeout(() => process.exit(1), 30_000).unref().

Backpressure côté serveur. Si res.write(chunk) retourne false, ton buffer interne est plein. Attends l'événement drain avant d'écrire à nouveau, sinon la mémoire du process explose face à un client lent.

Limit body size. Toujours définir une limite (en général 1-10 MiB selon le cas d'usage). Avec le module natif, lis le content-length et coupe la connexion si dépassement. Avec Fastify : bodyLimit. Avec Express : express.json({ limit: "1mb" }).

Connection upgrade (WebSocket). Le module http émet upgrade quand un client demande un protocole switch. C'est là que ws ou socket.io greffent leur logique. Tu peux mixer HTTP normal et WebSocket sur le même port.

ts
// Pattern : graceful shutdown
const connections = new Set<import("node:net").Socket>();
server.on("connection", (s) => {
  connections.add(s);
  s.on("close", () => connections.delete(s));
});

function shutdown() {
  console.log("SIGTERM received, draining...");
  server.close(() => process.exit(0));
  // Force la fermeture des keep-alives inactifs
  for (const s of connections) {
    if ((s as any)._httpMessage == null) s.destroy();
  }
  setTimeout(() => process.exit(1), 30_000).unref();
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

Routing manuel vs framework. Pour un service avec peu de routes (< 10), un switch sur req.url + req.method est plus rapide et explicite qu'un framework. Quand le nombre de routes augmente, ou que tu veux des paramètres (/users/:id), prends un router (find-my-way est ce que Fastify utilise sous le capot, c'est un radix tree très rapide). Évite de parser req.url avec des regex maison sur des entrées arbitraires : un attaquant peut exploiter du catastrophic backtracking pour faire un ReDoS.

Validation par schéma compilé. La pratique gagnante en 2026 est de définir un schéma JSON Schema ou Zod par route, puis de le compiler en une fonction de validation monomorphique. Fastify le fait avec ajv, ce qui rend la validation 5-10x plus rapide que des if/else manuels et empêche des bugs subtils (coerce des strings en number sans le vouloir, par exemple). Côté output, sérialise via un schéma aussi (fast-json-stringify) pour éviter de balayer dynamiquement la forme de l'objet.

Sérialisation streaming. Pour un endpoint qui retourne 100 MB de JSON, accumuler en mémoire est catastrophique. Utilise un sérialiseur streaming : JSONStream.stringify() ou écris à la main res.write('[') puis chaque élément suivi de , puis res.write(']'). Ou mieux, retourne du NDJSON (application/x-ndjson) — un objet par ligne, trivial à parser côté client en streaming. Ne combine jamais res.json() avec un dataset que tu ne peux pas garantir borné.

SSE (Server-Sent Events). Pour pousser des événements du serveur vers un navigateur sans WebSocket. Tu envoies des headers Content-Type: text/event-stream + Cache-Control: no-cache + Connection: keep-alive, puis du texte au format data: <json>\n\n. Avantage : ça passe partout (proxies HTTP, CDNs), reconnexion automatique côté navigateur, plus simple que WebSocket. Inconvénient : unidirectionnel, et un proxy avec proxy_buffering on (nginx par défaut) bloque tout — il faut désactiver le buffering en amont.

ts
// SSE endpoint
function sse(req: IncomingMessage, res: ServerResponse) {
  res.writeHead(200, {
    "content-type": "text/event-stream",
    "cache-control": "no-cache",
    "connection": "keep-alive",
    "x-accel-buffering": "no", // désactive buffering nginx
  });
  res.write("retry: 5000\n\n");
  const interval = setInterval(() => {
    res.write(`data: ${JSON.stringify({ t: Date.now() })}\n\n`);
  }, 1000);
  req.on("close", () => clearInterval(interval));
}

Cluster mode. Le module node:cluster permet de faire tourner N workers (un par CPU) partageant le même port. Utile quand ton workload est CPU-bound (parsing, compression, JSON). Sous Linux, le kernel répartit les connexions via SO_REUSEPORT. Alternative : faire tourner N process séparés derrière un load balancer (PM2, Docker + nginx). En 2026, node:cluster reste pertinent mais beaucoup d'équipes préfèrent une scale horizontale via containers.

Worker Threads pour CPU. Si une route fait du calcul lourd (génération PDF, hash bcrypt, ML inférence), bloque pas l'event loop. Délègue à un Worker via node:worker_threads. La latence des autres requêtes ne sera pas impactée. Communication via MessageChannel (zéro-copie pour les ArrayBuffer avec transferList).

🔄 Versions — Node 18 / 20 / 22 / 24

Node 18 (LTS jusqu'à avril 2025). fetch natif basé sur undici est désormais stable, plus besoin de node-fetch. http.Server.requestTimeout passe à 300 s par défaut (contre 0 = illimité avant), ce qui a cassé certains uploads longs. globalAgent HTTP a keepAlive: false par défaut — pense à le passer à true en client.

Node 20 (LTS jusqu'à avril 2026). Stabilisation du test runner (node --test) qui devient utilisable pour les tests d'intégration HTTP sans dépendance. --watch mode stable. Améliorations llhttp 9.x avec meilleur parsing des transferts chunked malformés. L'API node:http2 reçoit des correctifs autour des sessions zombies.

Node 22 (LTS depuis avril 2024, support jusqu'à avril 2027). WebSocket natif côté client (basé sur undici) sort de l'expérimental, plus besoin de ws pour faire client. Le module node:net gagne socket.resetAndDestroy() pour envoyer un RST TCP propre. globalAgent HTTP active keepAlive: true par défaut côté client, ce qui change le comportement de scripts existants. Maglev (V8) compile les fonctions hot plus rapidement, gain perceptible sur les frameworks.

Node 21 (current entre 20 et 22). Apporte le support de WebStreams complet et l'API Navigator.userAgent. Tu peux ignorer Node 21 en prod (cycle court), mais ses changements ont infusé en 22.

Node 24 (LTS depuis octobre 2025). Le HTTP client undici intégré gagne le support expérimental de HTTP/3 via node --experimental-http3 (package node:http3). Les serveurs HTTP/3 nécessitent encore une dépendance (@matrixai/quic, node-quic) — pas de http3.createServer natif. Le test runner inclut t.assert.snapshot() stable et l'isolation processus par fichier de test (--test-isolation=process). permission-model permet de restreindre les accès réseau du process (--permission --allow-net=api.example.com).

ts
// Node 24 — restriction réseau au niveau process
// node --permission --allow-net=api.example.com app.js
// Toute connexion sortante vers un autre host lève ERR_ACCESS_DENIED.

Comparaison de performance brute (chiffres indicatifs). Sur une machine moderne (AMD Ryzen 7950X, Node 22, route GET / retournant {"ok":true}, autocannon -c 100 -d 30) :

StackReq/sLatence p99Notes
node:http brut~80k2.1 msRéférence basse-latence
Fastify 5~65k2.5 msAvec schema validation activée
Koa~40k4.2 msLéger middleware overhead
Express 5~30k6.0 msPlus lent mais correct
Hono (sur node:http)~70k2.3 msTrès proche du raw
uWebSockets.js~150k1.2 msC++ binding, API non-standard

Ces chiffres ne reflètent qu'un endpoint trivial. En réalité, la majorité du temps est passée dans ta logique métier (DB, parsing, calculs). Le choix du framework HTTP impacte rarement plus de 5-10 % du débit global d'une vraie API.

🔐 Sécurité approfondie

Au-delà des timeouts, plusieurs vecteurs d'attaque méritent attention.

HTTP request smuggling. Une discordance entre la façon dont un front-end (proxy, LB) et un back-end (Node) interprètent la limite d'une requête peut permettre à un attaquant de "smuggler" une seconde requête dans le corps de la première. Vecteurs classiques : Content-Length vs Transfer-Encoding, headers dupliqués, casse mixte. Node 18+ rejette les requêtes ambiguës par défaut (--insecure-http-parser pour assouplir, à n'utiliser jamais en prod). Vérifie ta chaîne complète avec un outil comme PortSwigger HTTP Request Smuggler.

Prototype pollution via query string. Parsers naïfs (querystring legacy, certains middlewares) peuvent fusionner ?__proto__[admin]=1 dans un objet et polluer le prototype global. Utilise URLSearchParams natif qui retourne une Map, pas un Object. Pour les bodies JSON, surveille Object.freeze(Object.prototype) au démarrage ou utilise JSON.parse avec un reviver qui rejette les clés sensibles.

SSRF (Server-Side Request Forgery). Si ton serveur accepte une URL utilisateur et fait fetch(url) côté serveur, un attaquant peut t'envoyer fetch sur http://169.254.169.254/ (metadata service AWS), http://localhost:6379/ (Redis), etc. Toujours valider : (1) schéma whitelisté (https: only), (2) host non privé (net.isIP + net.BlockList avec les CIDR privés), (3) résolution DNS et re-validation de l'IP retournée pour éviter le DNS rebinding.

ts
import { lookup } from "node:dns/promises";
import { BlockList, isIPv4, isIPv6 } from "node:net";

const blocked = new BlockList();
blocked.addSubnet("10.0.0.0", 8, "ipv4");
blocked.addSubnet("172.16.0.0", 12, "ipv4");
blocked.addSubnet("192.168.0.0", 16, "ipv4");
blocked.addSubnet("127.0.0.0", 8, "ipv4");
blocked.addSubnet("169.254.0.0", 16, "ipv4");

async function safeFetch(rawUrl: string) {
  const url = new URL(rawUrl);
  if (url.protocol !== "https:") throw new Error("https only");
  const { address, family } = await lookup(url.hostname);
  const type = family === 4 ? "ipv4" : "ipv6";
  if (blocked.check(address, type)) throw new Error("blocked range");
  // Force la requête à utiliser l'IP résolue (anti-DNS-rebinding)
  return fetch(rawUrl, { headers: { host: url.host } });
}

JSON parsing DoS. Un JSON profond ou très large peut faire exploser le parser. Limite bodyLimit (1-10 MiB selon usage) et envisage secure-json-parse pour rejeter les clés __proto__ et constructor. Pour des charges streaming, n'utilise pas JSON.parse sur l'ensemble : utilise stream-json ou délimite en NDJSON.

Rate limiting. Toujours en place sur des endpoints sensibles (login, signup, reset password). Token bucket par IP+route, ou ratelimiter distribué (Redis avec rate-limiter-flexible). Attention aux IPs derrière proxy/Cloudflare : utilise X-Forwarded-For après vérification que la connexion vient d'un trusted proxy.

⚠️ Pitfalls — 6 à 10

  1. headersTimeout doit être inférieur à keepAliveTimeout côté load balancer. Si ton ALB AWS a un idle timeout de 60 s et que ton keepAliveTimeout Node est de 65 s, tu vas voir des ECONNRESET aléatoires côté client : l'ALB ferme la connexion alors que Node la croit encore vivante. Règle : keepAliveTimeout (Node) < idleTimeout (LB) et headersTimeout > keepAliveTimeout.
  2. Slowloris. Avant Node 18, headersTimeout était à 60 s mais sans requestTimeout, un attaquant pouvait maintenir une connexion ouverte indéfiniment en envoyant le body très lentement. Ajuste toujours les deux.
  3. CRLF injection dans les headers. Si tu fais res.setHeader("location", userInput) sans sanitiser, un \r\n permet d'injecter d'autres headers ou de splitter la réponse. Node valide depuis longtemps, mais les frameworks custom peuvent oublier.
  4. res.end() sans await sur le drain. Si tu écris en boucle sans gérer le retour false de res.write(), ta heap grossit. Symptôme : OOM sous charge avec clients lents.
  5. HTTP/2 et trailers vs HTTP/1.1. Les frameworks Express et Koa parlent HTTP/1.1 ; basculer en HTTP/2 ne fonctionne pas avec http2.createSecureServer directement, il faut utiliser compatibility API ou Fastify (qui le supporte nativement).
  6. Connection: close envoyé par erreur. Certains middlewares (sécurité, CORS) ajoutent Connection: close, ce qui désactive le keep-alive sans le vouloir. Vérifie tes headers en sortie avec curl -v.
  7. maxHeaderSize. Par défaut 16 KiB. Un système avec des cookies très lourds (sessions JWT verbeuses + analytics) peut dépasser et tu obtiens 431 Request Header Fields Too Large. Augmente avec --max-http-header-size=32768 au lancement.
  8. Agent global partagé entre tenants. Si tu utilises http.globalAgent ou un undici.Agent global, toutes les connexions sont mutualisées. C'est bien pour la perf, mais en multi-tenant tu peux fuiter des sockets entre tenants. Isole par tenant si tu fais du proxy multi-clients.
  9. Compression non gérée nativement. http.createServer ne fait pas de gzip/brotli automatiquement. Utilise un proxy (nginx, Caddy) ou un middleware (compression pour Express, intégré dans Fastify). Attention : ne compresse jamais du HTML rendu après un secret (BREACH/CRIME).
  10. HTTP/2 server push deprecated. Spec retirée par Chrome en 2022. N'investis pas dedans, utilise 103 Early Hints à la place (res.writeEarlyHints({ link: '</style.css>; rel=preload' }) depuis Node 18).
  11. Logging synchrone bloquant. console.log est synchrone vers stdout/stderr quand la sortie est un terminal ou un pipe. Sous charge, des dizaines de milliers de logs/s bloquent l'event loop. Utilise Pino (qui write asynchrone via pino.transport) ou écris dans un FD non bloquant.
  12. Compression mal placée. Compresser dans Node consomme CPU et threadpool. Si tu as un reverse proxy (nginx, Caddy), laisse-lui ce travail — il le fera plus vite et libérera Node pour les requêtes.

🧪 Testing — node --test + supertest pour HTTP

ts
// http-server.test.ts
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { once } from "node:events";
import { AddressInfo } from "node:net";

let server: ReturnType<typeof createServer>;
let baseUrl: string;

before(async () => {
  server = createServer((req, res) => {
    if (req.url === "/echo" && req.method === "POST") {
      let body = "";
      req.on("data", (c) => (body += c));
      req.on("end", () => {
        res.statusCode = 200;
        res.setHeader("content-type", "application/json");
        res.end(JSON.stringify({ echo: body }));
      });
      return;
    }
    res.statusCode = 404;
    res.end();
  });
  server.listen(0); // port 0 = OS attribue un port libre
  await once(server, "listening");
  const { port } = server.address() as AddressInfo;
  baseUrl = `http://127.0.0.1:${port}`;
});

after(() => new Promise((r) => server.close(() => r(null))));

test("POST /echo renvoie le body", async () => {
  const r = await fetch(`${baseUrl}/echo`, {
    method: "POST",
    body: "hello world",
  });
  assert.equal(r.status, 200);
  const j = (await r.json()) as { echo: string };
  assert.equal(j.echo, "hello world");
});

test("respecte AbortSignal côté client", async () => {
  const ctrl = new AbortController();
  setTimeout(() => ctrl.abort(), 10);
  await assert.rejects(
    () => fetch(`${baseUrl}/slow`, { signal: ctrl.signal }),
    /aborted/i
  );
});
bash
# Lance avec coverage et reporter TAP
node --test --experimental-test-coverage --test-reporter=spec http-server.test.ts

# Stress test avec autocannon (HTTP/1.1)
npx autocannon -c 100 -d 30 -p 10 http://localhost:3000/health

# Stress test HTTP/2 avec h2load
h2load -n 100000 -c 100 -m 10 https://localhost:8443/

Pour les tests d'intégration plus structurés, supertest reste pertinent (il sait monter un serveur éphémère), mais sa valeur ajoutée diminue depuis que fetch est natif. Avec node --test, tu peux tester n'importe quel framework en bootant sur le port 0 et en faisant des fetch vers 127.0.0.1:port.

Approche TDD pour API HTTP. Écris d'abord un test qui décrit le contrat (status code, headers, body shape), puis implémente. Utilise t.assert.snapshot() (Node 22+) pour fixer la forme des responses sans dupliquer les expectations dans plusieurs tests. Pour les tests de sécurité, ajoute des cas avec des inputs hostiles (header Host truqué, CRLF dans les params, body > limite).

ts
// Test de sécurité : limite de body
test("rejette les bodies > 1 MB", async () => {
  const big = "x".repeat(2 * 1024 * 1024);
  const r = await fetch(`${baseUrl}/upload`, { method: "POST", body: big });
  assert.equal(r.status, 413);
});

// Test de timing : pas de side channel sur auth
test("login : temps de réponse stable entre user inconnu et mauvais mot de passe", async () => {
  const t = async (body: object) => {
    const start = process.hrtime.bigint();
    await fetch(`${baseUrl}/login`, { method: "POST", body: JSON.stringify(body) });
    return Number(process.hrtime.bigint() - start) / 1e6;
  };
  const t1 = await t({ user: "doesnotexist", pwd: "x" });
  const t2 = await t({ user: "alice", pwd: "wrong" });
  assert.ok(Math.abs(t1 - t2) < 5, "diff timing < 5ms");
});

🎬 Cas d'usage concrets

Scénario 1 — API banque HTTP/2 multiplexée

Banque de détail exposant une API agrégée pour son app mobile (positions comptes, opérations, virements préparés, alertes). Sur 3G/4G dégradé, HTTP/1.1 souffre du head-of-line blocking : 6 connexions max, chaque endpoint sérialisé. Migration vers HTTP/2 via node:http2.

http2.createSecureServer({ key, cert, allowHTTP1: true }) permet aux clients legacy (script de test bash) de tomber en HTTP/1.1 et aux mobiles récents de multiplexer 50 streams sur une connexion TLS unique. Server push initial supprimé (déprécié), mais le multiplexing seul réduit le temps de chargement écran d'accueil de 1.9 s à 720 ms côté client.

Côté serveur, attention aux stream.respond({ ':status': 200 }) puis stream.end(body) — l'API diffère de node:http. Le rate limiting est par session HTTP/2 (par TLS) et non par stream pour éviter qu'un client honnête sature son propre quota en chargeant 30 ressources en parallèle.

Scénario 2 — E-commerce Fastify sur raw socket pour endpoint hot path

Marketplace e-commerce avec un endpoint /cart/add recevant 8 000 req/s en pic. Fastify est déjà en place pour le reste de l'API, mais ce endpoint spécifique demandait optimisations agressives.

Solution hybride : Fastify pour 99 % de l'API, et un handler node:http raw monté sur la même app via fastify.server.on('request', ...) pour /cart/add. Le handler raw parse manuellement (le payload est connu : {productId, qty} strict), valide via une fonction inline, push sur Redis, répond 204. Pas de plugins, pas de validation Zod sur le hot path (faite côté front + recheck async dans un worker BullMQ).

Gain mesuré : P50 de 4.2 ms → 1.1 ms, P99 de 28 ms → 6 ms. Évidemment, le code raw est testé en isolation et review obligatoire double — la dette technique est circonscrite.

Scénario 3 — Gateway cabinet juridique TLS mutual auth

Un cabinet expose une API de consultation de jurisprudence à 3 clients institutionnels (autres cabinets, ministère). Authentification par certificat client (mTLS) plutôt que par API key, exigée par la DSI client.

https.createServer({ key, cert, ca: trustedCAs, requestCert: true, rejectUnauthorized: true }) — Node rejette toute connexion sans certificat client signé par les CAs internes. Dans le handler, req.socket.getPeerCertificate() donne accès au CN/OU pour autoriser par identité. Logging audit RGPD inclut le serial du cert + l'opération.

Renouvellement automatisé via cert-manager + reload SIGHUP : le process attrape SIGHUP, recrée tls.createSecureContext(newCertPair) et server.setSecureContext(ctx) sans interrompre les connexions en cours. Zéro downtime sur rotation 90j Let's Encrypt.

🛠️ Exemple end-to-end

Serveur HTTP/2 + HTTP/1.1 secure avec mTLS optionnel, rate limit par session, timeouts hardenés, et reload TLS sans downtime — pattern complet d'un gateway production.

ts
import { createSecureServer, type ServerHttp2Stream } from "node:http2";
import { createSecureContext } from "node:tls";
import { readFile } from "node:fs/promises";

type Limiter = { count: number; resetAt: number };
const sessions = new WeakMap<object, Limiter>();
const RATE = { max: 100, windowMs: 10_000 };

async function loadCerts() {
  const [key, cert, ca] = await Promise.all([
    readFile("/etc/tls/server.key"),
    readFile("/etc/tls/server.crt"),
    readFile("/etc/tls/clients-ca.pem"),
  ]);
  return { key, cert, ca };
}

const initial = await loadCerts();
const server = createSecureServer({
  ...initial,
  allowHTTP1: true,
  requestCert: true,
  rejectUnauthorized: false, // we check manually for mixed public/mTLS routes
});

server.on("session", (session) => {
  sessions.set(session, { count: 0, resetAt: Date.now() + RATE.windowMs });
  session.setTimeout(60_000, () => session.close());
});

function hitRate(session: object) {
  const now = Date.now();
  const l = sessions.get(session) ?? { count: 0, resetAt: now + RATE.windowMs };
  if (now > l.resetAt) {
    l.count = 0;
    l.resetAt = now + RATE.windowMs;
  }
  l.count++;
  sessions.set(session, l);
  return l.count <= RATE.max;
}

function isMtlsRoute(path: string) {
  return path.startsWith("/internal/");
}

server.on("stream", (stream: ServerHttp2Stream, headers) => {
  const path = String(headers[":path"] ?? "/");
  const method = String(headers[":method"] ?? "GET");

  if (!hitRate(stream.session as object)) {
    stream.respond({ ":status": 429, "retry-after": "10" });
    return stream.end();
  }

  if (isMtlsRoute(path)) {
    const peer = (stream.session as any).socket?.getPeerCertificate?.();
    if (!peer || !peer.subject?.CN) {
      stream.respond({ ":status": 401 });
      return stream.end("client certificate required");
    }
    // audit log
    console.log(`[mtls] ${method} ${path} cn=${peer.subject.CN} serial=${peer.serialNumber}`);
  }

  if (method === "GET" && path === "/health") {
    stream.respond({ ":status": 200, "content-type": "application/json" });
    return stream.end(JSON.stringify({ ok: true, ts: Date.now() }));
  }

  stream.respond({ ":status": 404 });
  stream.end();
});

// hot reload TLS on SIGHUP (cert-manager rotation)
process.on("SIGHUP", async () => {
  try {
    const next = await loadCerts();
    server.setSecureContext(createSecureContext(next));
    console.log("[tls] reloaded");
  } catch (err) {
    console.error("[tls] reload failed", err);
  }
});

server.on("error", (err) => console.error("[server]", err));
server.listen(8443, () => console.log("https://0.0.0.0:8443"));

// graceful shutdown
for (const sig of ["SIGTERM", "SIGINT"] as const) {
  process.on(sig, () => {
    server.close(() => process.exit(0));
    setTimeout(() => process.exit(1), 10_000).unref();
  });
}

Points clés : HTTP/2 multiplexé avec fallback H1 pour clients legacy, mTLS sélectif par route, rate limit par session TLS, reload SIGHUP sans interruption, timeouts session, graceful shutdown borné.


🔁 Quand utiliser / éviter

Utilise node:http directement quand : tu construis un proxy, un edge service ultra-light, un middleware de plateforme (rate limiter, auth gateway), ou que la perf brute prime sur le confort. Tu obtiens 30 à 50 % de débit en plus qu'Express, et tu contrôles tout (timeouts, parsing partiel, streaming fin).

Utilise Fastify quand : tu écris une API REST/RPC normale. Tu gagnes la validation JSON Schema (qui compile en code monomorphique avec ajv, donc plus rapide que Joi/Zod en runtime), le logger Pino, un système de plugins propre. Latence p99 typique 1-3 ms vs 3-8 ms pour Express.

Utilise Express quand : tu travailles sur du legacy, ton équipe le connaît, ou tu as besoin d'un écosystème de middlewares très mature. La perf est en retrait mais la productivité reste solide. Express 5 (sorti en 2024) corrige les pires problèmes (gestion d'async, promises rejetées).

Utilise uWebSockets.js quand : tu pousses du WebSocket à haute fréquence (jeux temps réel, trading) ou tu vises 100k+ req/s par instance. Inconvénient : c'est un binding C++, débuggage plus délicat, API différente du standard Node.

Évite HTTP/2 en sortie publique sans CDN. En général, ton CDN (CloudFront, Fastly, Cloudflare) parle HTTP/2/3 au client et HTTP/1.1 keep-alive vers ton origine. Inutile d'activer h2 sur ton serveur Node sauf cas spécifique (gRPC, services internes).

Évite HTTP/3 en production en 2026 sauf via un proxy mature (Caddy, nginx-quic, HAProxy). Le support natif Node reste expérimental.

Côté client : undici par défaut. Le fetch natif est déjà undici sous le capot. Utilise undici.Agent quand tu as besoin de tuning fin (pooling, mocking, proxy). Réserve node:https.request aux cas où tu as besoin d'introspecter le socket TLS.

Observabilité minimale. Trois métriques à exporter dès le premier déploiement : (1) compteur de requêtes par statut HTTP (http_requests_total{status="200"}), (2) histogramme de durée (http_request_duration_seconds par route), (3) gauge de connexions actives (http_connections_active). Avec OpenTelemetry, tu obtiens cela quasiment gratuitement via @opentelemetry/instrumentation-http. Sans observabilité, diagnostiquer un incident en prod sur un serveur HTTP devient une devinette.

Sécurité minimale. Headers à ajouter systématiquement sur toute réponse : Strict-Transport-Security: max-age=31536000; includeSubDomains, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Content-Security-Policy adapté. Le package helmet les gère pour Express ; en raw, res.setHeader direct. Pour CORS, n'utilise jamais Access-Control-Allow-Origin: * avec credentials — un site malveillant pourrait lire les réponses authentifiées.

🌐 HTTP/3 et QUIC en 2026 — état de l'art

Le protocole QUIC (RFC 9000) tourne sur UDP et remplace TCP+TLS pour HTTP/3 (RFC 9114). Avantages : 0-RTT pour les connexions ré-établies (le client présente un token et envoie déjà sa requête), pas de head-of-line blocking au niveau transport (chaque stream est indépendant), connexion migration (un téléphone qui passe de WiFi à 4G garde sa connexion). Inconvénients : impl complexe en user-space, support OS partiel, et beaucoup d'opérateurs filtrent UDP > 1500 bytes ou rate-limit UDP différemment de TCP.

Côté Node.js, le support HTTP/3 reste expérimental en 2026. Pour servir HTTP/3, deux options :

  1. Délégation à un reverse proxy. Caddy active HTTP/3 par défaut. nginx-quic et HAProxy 2.6+ supportent QUIC. Tu laisses le proxy parler HTTP/3 au client, il traduit en HTTP/1.1 ou HTTP/2 vers ton Node. Recommandation pour 95 % des cas.
  2. Library directe. @matrixai/quic et node-quic exposent une API QUIC. Tu construis HTTP/3 par-dessus. Tu paies en complexité et tu te retrouves à maintenir le mapping QUIC streams ↔ HTTP/3 frames toi-même.

Côté client, fetch natif Node 24+ avec --experimental-http3 peut négocier HTTP/3 si le serveur le supporte. Le header Alt-Svc annonce la disponibilité H3, et le client suivant utilisera UDP/443.

ts
// Node 24+ : client HTTP/3 expérimental
// node --experimental-http3 client.ts
const r = await fetch("https://h3.example.com/api", {
  // @ts-expect-error — option non standard
  dispatcher: new (await import("undici")).Agent({ allowH2: true, allowH3: true }),
});

📊 Métriques RED en pratique

Le modèle RED (Rate, Errors, Duration), inspiré par Tom Wilkie, est le minimum vital pour un service HTTP.

  • Rate : requêtes par seconde, ventilée par route et statut. Permet de détecter une chute de trafic anormale, ou un endpoint qui sature.
  • Errors : taux d'erreur 5xx + erreurs métier 4xx significatives (401, 403, 422). Un pic d'erreurs 401 sur /login peut signaler un credential stuffing.
  • Duration : histogramme des latences. Préfère p50/p95/p99 plutôt que la moyenne, qui masque les outliers. Un p99 qui explose pendant que p50 reste stable indique une queue (souvent DB ou external API).
ts
// Middleware minimal de timing
function timing(req: IncomingMessage, res: ServerResponse, next: () => void) {
  const start = process.hrtime.bigint();
  res.on("finish", () => {
    const ms = Number(process.hrtime.bigint() - start) / 1e6;
    const route = (req as any).route ?? req.url?.split("?")[0] ?? "unknown";
    metrics.histogram("http_duration_ms", ms, { route, status: String(res.statusCode) });
  });
  next();
}

L'erreur classique est de logger la durée mais pas le statut, ou d'agréger toutes les routes ensemble : tu perds la résolution nécessaire pour trouver la cause. Une route lente cachée dans une moyenne globale rapide passe inaperçue.

🛡️ Patterns de production éprouvés

Connection limit. Configure server.maxConnections ou compte manuellement. Sous attaque, sans limite, tu peux te faire OOM par des dizaines de milliers de connexions ouvertes (chacune avec ses buffers).

Health vs readiness. Sépare deux endpoints :

  • /healthz : process vivant ? Retourne 200 si l'event loop n'est pas bloqué. Utilisé par K8s pour décider de killer le pod.
  • /readyz : prêt à recevoir du trafic ? Vérifie DB, cache, dépendances critiques. Si une dépendance est down et le service ne peut pas servir, retourne 503. Utilisé par K8s pour décider d'envoyer du trafic.
ts
let isReady = false;
let isShuttingDown = false;

async function readyCheck(): Promise<boolean> {
  if (isShuttingDown) return false;
  try {
    await Promise.all([db.ping(), redis.ping()]);
    return true;
  } catch { return false; }
}

server.on("request", (req, res) => {
  if (req.url === "/healthz") { res.statusCode = 200; res.end("ok"); return; }
  if (req.url === "/readyz") {
    readyCheck().then((ok) => { res.statusCode = ok ? 200 : 503; res.end(); });
    return;
  }
  // ...
});

Connection draining au shutdown. À SIGTERM, mets isShuttingDown = true (les readyz commencent à répondre 503 → K8s arrête de t'envoyer du trafic), attends ~5 s pour que le LB s'en aperçoive, puis server.close() et attends les requêtes en cours.

🧩 Intégration avec proxy/CDN

Quand ton Node.js est derrière un reverse proxy (nginx, Caddy, ALB, Cloudflare), plusieurs comportements changent :

  • IP cliente : req.socket.remoteAddress est l'IP du proxy, pas du vrai client. Lis X-Forwarded-For (mais valide que la connexion vient d'un proxy de confiance ; sinon n'importe qui peut spoofer). Avec Express : app.set('trust proxy', ...). Avec Fastify : option trustProxy.
  • Protocole d'origine : req.socket.encrypted te dit si Node parle TLS, pas si le client parlait TLS au proxy. Lis X-Forwarded-Proto.
  • Host original : req.headers.host peut être réécrit. Préfère X-Forwarded-Host si configuré.
  • Connection reuse : le proxy garde des keep-alives vers Node. Tune keepAliveTimeout en conséquence (cf. pitfall 1).
ts
// Helper pour récupérer la vraie IP client
function clientIP(req: IncomingMessage, trustedProxies: string[]): string {
  if (!trustedProxies.includes(req.socket.remoteAddress ?? "")) {
    return req.socket.remoteAddress ?? "";
  }
  const xff = req.headers["x-forwarded-for"];
  if (!xff) return req.socket.remoteAddress ?? "";
  const list = Array.isArray(xff) ? xff[0] : xff;
  // Prend la première IP non-trusted (left-most que tu peux vérifier)
  return list.split(",")[0]?.trim() ?? req.socket.remoteAddress ?? "";
}

🧮 Le raisonnement d'un staff engineer

Quand on te confie « rends ce serveur production-ready », un junior ajoute des try/catch. Un senior raisonne par budgets et modes de défaillance, pas par features. La grille mentale :

  1. Quel est mon budget de latence, et où part-il ? Décompose p99 = accept + parse + middleware + handler (DB/IO) + serialize + flush. 90 % du temps est dans handler. Optimiser le framework HTTP (le parse) avant d'avoir profilé le handler, c'est optimiser le mauvais terme. Mesure d'abord avec --prof / clinic flame / OTel spans par phase.
  2. Que se passe-t-il quand une dépendance est lente, pas down ? Le mode le plus dangereux n'est pas le crash, c'est la dégradation partielle. Une DB à 200 ms au lieu de 5 ms → les requêtes s'accumulent → l'event loop ne sature pas mais les sockets in-flight explosent → OOM ou file d'attente infinie. Réponse : timeouts côté client de chaque dépendance (jamais "infini"), un budget de concurrence (p-limit, semaphore), et un circuit breaker qui fail-fast en 503 plutôt que de laisser pourrir.
  3. Quel est le couplage temporel entre mes timeouts ? La règle d'or se chaîne : client timeout > LB idle timeout > Node keepAliveTimeout, et headersTimeout > keepAliveTimeout. Toute inversion produit des ECONNRESET aléatoires côté client — le bug le plus coûteux à diagnostiquer car intermittent et invisible en local.
  4. Où sont mes limites dures ? Tout ce qui vient du réseau est non borné par défaut : taille du body, nombre de headers, nombre de connexions, profondeur du JSON, durée d'une requête. Un staff engineer liste ces 5 axes et met une borne explicite sur chacun avant de déployer. Une seule borne manquante = un vecteur DoS.
  5. Comment je le déploie sans tuer des requêtes ? Le readiness flip (503 sur /readyz) + le délai d'observation LB + server.close() borné est le seul pattern qui garantit zéro requête tuée en rolling deploy. Sans lui, chaque deploy coûte des 502 aux utilisateurs.

Le réflexe : borne, mesure, dégrade gracieusement. Jamais « ça marche en local ».

🏋️ Exercices

Progression : du serveur correct (impl) → durci pour la prod (production-grade) → casse puis répare (break-then-fix). Fais-les avec Node 22+ et node:test. Pas de framework sauf mention contraire.

Exercice 1 — Serveur d'écho borné (impl)

Objectif : écrire un node:http qui lit un body POST JSON, le renvoie, mais rejette en 413 dès que le cumul dépasse 256 KiB pendant le streaming (pas après l'avoir tout bufferisé).

Indice / Solution

Accumule la taille dans le handler req.on('data') ; dès que received > LIMIT, réponds 413, fais req.destroy() et n'écris plus rien. Le piège : si tu attends 'end' pour vérifier content-length, tu as déjà bufferisé l'attaque. Vérifie aussi content-length en amont comme court-circuit, mais ne lui fais pas confiance (il peut mentir vs le corps réel). Teste avec un body de 512 KiB et assert 413.

Exercice 2 — Les 5 timeouts cohérents (production-grade)

Objectif : construire un serveur avec headersTimeout, requestTimeout, keepAliveTimeout, un timeout par handler (via AbortController), et un timeout par appel sortant (undici headersTimeout/bodyTimeout). Écris un test qui prouve qu'un client slowloris (envoie 1 octet de header / seconde) est coupé en < 2 s.

Indice / Solution

server.headersTimeout = 2000. Pour le slowloris de test, ouvre un net.connect, écris "GET / HTTP/1.1\r\n" puis un octet toutes les secondes ; assert que le socket reçoit un close/'end' avant 3 s. Pour le handler timeout : const ac = new AbortController(); const t = setTimeout(() => ac.abort(), 5000) et passe ac.signal aux opérations IO ; au abort, réponds 503 et clearTimeout. Documente la chaîne d'inégalités en commentaire.

Exercice 3 — Graceful shutdown vérifiable (production-grade)

Objectif : SIGTERM doit (a) flip /readyz → 503, (b) laisser finir une requête longue en cours (un endpoint /slow de 3 s), (c) refuser les nouvelles requêtes, (d) sortir avant un hard-deadline de 10 s. Prouve-le par un test qui démarre /slow, envoie SIGTERM 1 s après, puis lance une 2e requête (doit échouer/être refusée) et vérifie que la 1re répond bien 200.

Indice / Solution

Track les sockets dans un Set (cf. pattern graceful shutdown du doc). À SIGTERM : isShuttingDown = true ; server.close(cb) ; pour fermer les keep-alives inactifs seulement, teste _httpMessage == null. Le test : process.kill(process.pid, 'SIGTERM') ne marche pas dans le même process pour ce scénario — fork un worker (child_process.fork) qui héberge le serveur, et pilote-le depuis le parent. Assert l'ordre : 1re requête 200, 2e ECONNREFUSED, code de sortie 0.

Exercice 4 — Anti-SSRF avec anti-DNS-rebinding (break-then-fix)

Objectif : pars du safeFetch du doc. Casse-le : démontre que valider l'IP au moment du lookup puis appeler fetch(rawUrl) laisse une fenêtre TOCTOU (le DNS peut renvoyer une IP publique à la validation et 169.254.169.254 à la connexion réelle). Puis répare : force la connexion sur l'IP déjà validée.

Indice / Solution

Le bug : entre await lookup() et fetch(rawUrl), Node refait sa propre résolution DNS — un serveur DNS contrôlé par l'attaquant (TTL 0) renvoie une IP différente. Reproduis avec un faux résolveur ou un host dont le DNS bascule. Fix : ne passe plus jamais le hostname à fetch. Soit tu connectes sur l'IP validée et fixes le SNI/Host (new URL avec l'IP + header host + servername TLS), soit tu utilises un undici.Agent avec un connect custom qui reçoit l'IP déjà résolue. Re-vérifie l'IP à l'intérieur du callback connect. Bonus : bloque aussi IPv6 mappé (::ffff:169.254.169.254) et 0.0.0.0.

Exercice 5 — Streaming NDJSON avec back-pressure honnête (production-grade)

Objectif : exposer /export qui streame 1 000 000 d'objets en NDJSON sans jamais bufferiser plus de quelques lignes. Le RSS du process doit rester plat (< +50 MiB) même si le client lit lentement. Prouve-le en mesurant process.memoryUsage().rss pendant un client qui consomme à 1 ligne/10 ms.

Indice / Solution

N'utilise pas res.write en boucle serrée : respecte le retour false et attends 'drain'. Le plus propre : un Readable (ou générateur async) pipe() dans res — le back-pressure est géré automatiquement. Avec for await, fais if (!res.write(line)) await once(res, 'drain'). Le test : lance le serveur, fetch en lisant le body reader lentement, échantillonne rss toutes les 100 ms, assert max - min < 50 * 2**20. Si le RSS grimpe linéairement, tu as oublié le drain.

Exercice 6 — Detecter le request smuggling en local (break-then-fix)

Objectif : monte un mini front-proxy (Node) qui forward vers un back-end (Node) et fabrique une attaque CL.TE (Content-Length + Transfer-Encoding: chunked contradictoires). Observe que le parser strict de Node 18+ rejette. Puis lance le back-end avec --insecure-http-parser et montre que la requête smugglée passe — pour comprendre pourquoi on ne touche jamais ce flag en prod.

Indice / Solution

Forge la requête à la main via net.connect (impossible avec fetch, qui normalise) : envoie des headers avec à la fois Content-Length: 6 et Transfer-Encoding: chunked et un corps construit pour cacher une 2e requête. Sans flag : Node renvoie 400 (HPE_INVALID_... / rejet TE+CL). Avec --insecure-http-parser : le comportement diverge entre proxy et back-end → la requête fantôme est traitée. Conclusion à écrire : la défense réelle est l'uniformité de parsing sur toute la chaîne + ne jamais désactiver le parser strict.

🎤 En entretien

Q : Pourquoi voit-on des ECONNRESET intermittents côté client alors que le serveur Node semble sain ? R : Inversion de la chaîne de timeouts. Si keepAliveTimeout de Node ≥ idle timeout du LB/ALB, le LB ferme une connexion keep-alive que Node croit encore vivante et réutilise → RST. Règle : Node keepAliveTimeout < LB idle timeout et headersTimeout > keepAliveTimeout. C'est intermittent car ça ne touche que les connexions réutilisées juste à la frontière de l'idle.

Q : node:cluster ou plusieurs conteneurs derrière un LB ? Comment tu choisis ? R : Même but (utiliser les N CPU), couches différentes. cluster/SO_REUSEPORT partage un process parent : déploiement atomique, mémoire partagée possible, mais une fuite/crash du master tue tout et le scaling est lié à la machine. Conteneurs : isolation, scaling horizontal élastique, rolling deploy granulaire, observabilité par instance — au prix d'un peu d'overhead et d'un LB. En 2026 par défaut : 1 process par conteneur, scale par réplicas ; cluster seulement pour saturer une grosse VM bare-metal sans orchestrateur.

Q : Tu sers 100 MB de JSON. Quels sont les modes de défaillance et la bonne archi ? R : res.json() / JSON.stringify sur tout l'objet → pic mémoire = taille du dataset × (objet JS + string), GC pauses, OOM sous concurrence. La bonne réponse : streaming. NDJSON (un objet/ligne, application/x-ndjson) parsable en flux côté client, ou un sérialiseur streaming + back-pressure (res.write qui respecte drain). Et borne toujours : pagination/cursor plutôt qu'un export non borné sur le hot path.

Q : Comment garantis-tu zéro requête tuée pendant un rolling deploy K8s ? R : Sépare /healthz (liveness) et /readyz (readiness). À SIGTERM : flip isShuttingDown/readyz répond 503, attends ~5 s que le LB/kube-proxy retire l'endpoint (les preStop hooks + terminationGracePeriodSeconds doivent couvrir ce délai), puis server.close() pour drainer les in-flight, avec un hard-deadline setTimeout(...).unref(). Tuer le process directement à SIGTERM ou fermer le serveur avant le délai d'observation LB = des 502 garantis.

🔗 Liens


Récap final. Un serveur Node.js robuste tient sur trois piliers : (1) configuration explicite des trois timeouts (headersTimeout, requestTimeout, keepAliveTimeout) cohérente avec le load balancer en amont ; (2) gestion correcte du back-pressure et du graceful shutdown pour éviter les fuites mémoire et les requêtes interrompues ; (3) choix conscient du protocole (HTTP/1.1 + CDN dans 90 % des cas, HTTP/2 pour gRPC et services internes, HTTP/3 quand l'écosystème mûrira). Le framework (Express vs Fastify vs uWS) compte moins que la discipline sur ces fondations. Côté client, undici via fetch natif est le défaut moderne, avec Agent pour tuner le pool et AbortSignal.timeout pour borner toute requête.

Bibliothèque tech perso — Achref