TCP, UDP, TLS et sockets bas niveau en Node.js
TL;DR — Quand tu sors du monde HTTP, tu plonges dans
node:net(TCP),node:dgram(UDP),node:tls(sockets chiffrés) et les sockets de domaine Unix. Le modulenetreproduit fidèlement les sockets POSIX, ce qui signifie que tu es responsable du framing (TCP est un flux d'octets, pas un transport de messages — il faut préfixer par longueur ou délimiter par ligne). Le back-pressure (socket.writeretournefalse→ attends'drain') est crucial : ignorer ça, c'est faire exploser la mémoire face à un consommateur lent. Les erreurs réseau usuelles (ECONNRESET,EPIPE,ETIMEDOUT,EHOSTUNREACH) doivent toutes être gérées explicitement, sinon ton process crashe. UDP brille pour multicast/broadcast, télémetrie haute fréquence et discovery, mais n'oublie pas que rien n'est garanti (ni ordre, ni livraison). Les sockets domaines Unix sont le meilleur transport pour de l'IPC local (latence sous-ms, pas de TCP overhead).
🧠 Mental model — ASCII + analogie
TCP est un flux téléphonique : tu décroches, tu parles, tu raccroches. Les mots arrivent dans l'ordre, mais rien ne te dit où commencent et finissent les phrases — c'est à toi d'inventer la convention (point final, longueur préfixée). UDP est une carte postale : tu jettes ton message à la boîte, peut-être que le destinataire la reçoit, peut-être pas, peut-être deux fois, peut-être dans le désordre.
┌──────────────────────────────────────────────┐
│ Node.js process │
│ │
│ net.Server / dgram.Socket / tls.Server │
│ │ │ │ │
│ └──────────────┼───────────┘ │
│ ▼ │
│ libuv (epoll/kqueue/IOCP) │
└──────────────────────────────────────────────┘
│
──────────────────────────────────────────────
│
┌────────────────────┴────────────────────┐
│ kernel TCP/IP │
│ │
│ TCP socket : SYN/ACK, seq, window, │
│ Nagle, Delayed ACK │
│ │
│ UDP socket : send-and-forget, MTU, │
│ multicast, broadcast │
│ │
│ Unix socket : pas de réseau, juste │
│ IPC noyau │
└─────────────────────────────────────────┘
TCP framing (length-prefixed, big-endian uint32) :
┌──────────┬──────────────────────────────────────┐
│ LEN=42 │ payload (42 octets) │
└──────────┴──────────────────────────────────────┘
Backpressure :
socket.write(chunk) ──▶ buffer interne
│
│ trop plein ?
▼
return false ──▶ application attend 'drain'L'analogie clé : un socket Node.js est un Duplex stream — un Readable + un Writable cousus ensemble. Toutes les notions de stream s'appliquent (back-pressure, pipe, 'end', 'finish'). C'est pour ça qu'un socket peut se piper directement vers un autre (socketA.pipe(socketB)) — c'est ainsi qu'on construit un proxy TCP en 5 lignes.
🛠️ Code minimal (ts/js)
// 1. Serveur TCP avec framing length-prefixed
import { createServer, Socket } from "node:net";
const PORT = 9000;
function frame(payload: Buffer): Buffer {
const len = Buffer.alloc(4);
len.writeUInt32BE(payload.length, 0);
return Buffer.concat([len, payload]);
}
function attachReader(socket: Socket, onMessage: (msg: Buffer) => void) {
let buf = Buffer.alloc(0);
socket.on("data", (chunk) => {
buf = Buffer.concat([buf, chunk]);
while (buf.length >= 4) {
const len = buf.readUInt32BE(0);
if (len > 16 * 1024 * 1024) { // garde-fou : 16 MiB max
socket.destroy(new Error("frame_too_large"));
return;
}
if (buf.length < 4 + len) break; // attente du reste
const msg = buf.subarray(4, 4 + len);
buf = buf.subarray(4 + len);
onMessage(msg);
}
});
}
const server = createServer((socket) => {
socket.setNoDelay(true); // désactive Nagle
socket.setKeepAlive(true, 30_000); // keep-alive TCP au niveau kernel
socket.setTimeout(60_000); // inactivité
socket.on("timeout", () => socket.end());
socket.on("error", (e: NodeJS.ErrnoException) => {
if (e.code !== "ECONNRESET") console.error("sock error:", e);
});
attachReader(socket, (msg) => {
const reply = frame(Buffer.from(`echo:${msg.toString("utf8")}`));
const ok = socket.write(reply);
if (!ok) socket.pause(); // signale au lecteur d'attendre
});
socket.on("drain", () => socket.resume());
});
server.listen(PORT, () => console.log(`TCP listening on :${PORT}`));// 2. Client TCP avec reconnect exponentiel + jitter
// PIÈGE classique : on ne peut PAS "réutiliser" un Socket fermé.
// Un Socket Node n'est pas reconnectable — il faut en créer un neuf
// à chaque tentative. On expose donc un EventEmitter stable au-dessus
// d'un socket interne volatile.
import { connect, Socket } from "node:net";
import { EventEmitter } from "node:events";
class ReconnectingClient extends EventEmitter {
private socket: Socket | null = null;
private attempt = 0;
private stopped = false;
constructor(private host: string, private port: number) {
super();
this.dial();
}
private dial(): void {
if (this.stopped) return;
const s = connect({ host: this.host, port: this.port });
this.socket = s;
s.on("connect", () => {
this.attempt = 0; // reset du backoff seulement APRÈS un vrai connect
this.emit("up", s);
});
// Indispensable : sans listener 'error', un ECONNREFUSED crashe le process.
s.on("error", (err) => this.emit("warn", err));
s.on("data", (chunk) => this.emit("data", chunk));
s.once("close", () => {
this.socket = null;
this.emit("down");
if (this.stopped) return;
// Full jitter (AWS) : delay aléatoire dans [0, cap] — évite le thundering herd
const cap = Math.min(30_000, 200 * 2 ** this.attempt);
const delay = Math.random() * cap;
this.attempt++;
setTimeout(() => this.dial(), delay).unref();
});
}
write(data: Buffer | string): boolean {
if (!this.socket?.writable) return false; // pas de write sur un socket mort
return this.socket.write(data);
}
close(): void {
this.stopped = true;
this.socket?.destroy();
}
}Pourquoi
unref()sur le timer ? Sans lui, le timer de reconnexion garde l'event loop vivante : ton process ne peut plus sortir proprement pendant la fenêtre de backoff. Avecunref(), le timer n'empêche pas leprocess.exitnaturel mais s'exécute s'il reste d'autres handles actifs.
// 3. Serveur TLS (chiffré) avec ALPN
import { createServer as createTLSServer } from "node:tls";
import { readFileSync } from "node:fs";
const tlsServer = createTLSServer({
key: readFileSync("./key.pem"),
cert: readFileSync("./cert.pem"),
ALPNProtocols: ["myproto/1"], // négociation de sous-protocole
minVersion: "TLSv1.3",
requestCert: false, // mTLS : passe à true + 'ca'
honorCipherOrder: true,
}, (sock) => {
console.log("cipher:", sock.getCipher(), "alpn:", sock.alpnProtocol);
sock.write("hello over TLS\n");
});
tlsServer.listen(8443);// 4. UDP — telemetry / metrics / discovery
import { createSocket } from "node:dgram";
const udp = createSocket("udp4");
udp.bind(41234, () => {
udp.setBroadcast(true);
// udp.addMembership("224.0.0.114"); // multicast IPv4
});
udp.on("message", (msg, rinfo) => {
console.log(`from ${rinfo.address}:${rinfo.port} -> ${msg.toString()}`);
});
udp.on("error", (e) => { console.error("udp:", e); udp.close(); });
// Envoi point-à-point
udp.send("ping", 41234, "127.0.0.1");
// Broadcast subnet
udp.send("hello-network", 41234, "255.255.255.255");// 5. Socket de domaine Unix (IPC local)
import { createServer, connect } from "node:net";
import { unlinkSync, existsSync } from "node:fs";
const SOCK = "/tmp/myapp.sock";
if (existsSync(SOCK)) unlinkSync(SOCK);
createServer((s) => {
s.write("welcome\n");
s.pipe(s); // echo
}).listen(SOCK, () => {
// chmod 0600 le socket si tu veux restreindre
});
const c = connect(SOCK);
c.on("data", (d) => console.log("client got:", d.toString()));
c.write("hi\n");🎯 Patterns courants
Length-prefixed framing. Standard de facto pour les protocoles binaires (gRPC, Kafka, AMQP, BSON-based). Header de 4 octets (uint32 big-endian) = taille du payload, puis le payload. Simple à parser, robuste, et permet de pré-allouer le buffer côté lecteur. Toujours mettre un cap (16 MiB par exemple) pour éviter les attaques DoS qui demanderaient à allouer 4 GiB.
Line-delimited (newline framing). Pratique pour les protocoles textuels (Redis RESP, IRC, SMTP). Bonus : debuggable au telnet/nc. Inconvénient : impossible de transporter des bytes binaires bruts sans encoding (base64). Watch out for \r\n vs \n.
TCP keep-alive vs application keep-alive. TCP keep-alive (socket.setKeepAlive(true, 30_000)) demande au kernel d'envoyer des ACK probe toutes les N ms si la connexion est inactive. Mais sur Linux par défaut, le premier probe est envoyé après 2 heures (tcp_keepalive_time) — quasi-inutile sans tuning sysctl. En pratique, implémente un heartbeat applicatif (ping/pong toutes les 15-30 s) pour détecter rapidement les connexions zombies.
Désactiver Nagle. L'algorithme de Nagle bufferise les petits writes jusqu'à recevoir un ACK pour éviter la fragmentation. Pour un protocole interactif (REPL, gaming, RPC à latence basse), cela ajoute ~40 ms d'attente. Désactive avec socket.setNoDelay(true). Garde-le activé pour des transferts bulk.
Backpressure rigoureux. La règle d'or : si socket.write(chunk) retourne false, arrête de pousser de la data jusqu'à 'drain'. Si tu lis d'un autre stream pour pousser ailleurs, pause() la source. Le module stream/promises.pipeline fait tout cela automatiquement — préfère-le à des on('data') artisanaux.
Proxy TCP. Cinq lignes : accepte une connexion entrante, ouvre une sortante vers la cible, pipe chacune vers l'autre. Magique mais gère les erreurs et les fermetures pour ne pas fuiter de socket.
import { createServer, connect } from "node:net";
createServer((client) => {
const upstream = connect({ host: "backend", port: 5432 });
client.pipe(upstream).pipe(client);
client.on("error", () => upstream.destroy());
upstream.on("error", () => client.destroy());
}).listen(15432);UDP multicast pour service discovery. mDNS/Bonjour utilise UDP multicast (224.0.0.251:5353) pour annoncer les services LAN. Ton service envoie périodiquement un paquet d'annonce, les autres écoutent. Évite le coût d'un service registry pour des architectures LAN.
Unix sockets vs TCP localhost. Pour de l'IPC machine-locale (Node + Postgres dans le même container, sidecar vers main process), Unix socket bat TCP loopback : pas de checksums, pas de buffers stack TCP/IP, latence sous la ms, et permissions filesystem pour la sécurité. PostgreSQL, MySQL, nginx l'utilisent par défaut quand host est un chemin.
Connection draining. Avant un shutdown, arrête d'accepter de nouvelles connexions (server.close()) mais laisse les existantes finir leur travail. Pour TCP custom, envoie un message applicatif "goodbye" pour que le client sache qu'il doit se reconnecter ailleurs (utile en blue/green deployment). Délai max raisonnable : 30 s.
Connection metadata. À l'ouverture, capture socket.remoteAddress, socket.remotePort, socket.localAddress et un ID interne. Logge-les avec chaque event. Pour le debug, c'est inestimable : tu peux retrouver la trajectoire complète d'une connexion défaillante dans tes logs.
import { randomUUID } from "node:crypto";
server.on("connection", (socket) => {
const id = randomUUID();
const meta = {
id,
remoteAddr: `${socket.remoteAddress}:${socket.remotePort}`,
localAddr: `${socket.localAddress}:${socket.localPort}`,
openedAt: Date.now(),
};
console.log("conn open", meta);
socket.on("close", (hadError) => {
console.log("conn close", { ...meta, hadError, durationMs: Date.now() - meta.openedAt });
});
});Heartbeats applicatifs. Indispensable pour détecter rapidement les peers morts (kernel TCP n'envoie un keep-alive qu'après 2h par défaut). Envoie un ping toutes les 15-30 s ; si pas de pong sous 5-10 s, considère la connexion morte et destroy().
Pooling de connexions sortantes. Pour appeler un service externe TCP, n'ouvre/ferme pas à chaque requête. Garde un pool (style generic-pool) avec validation à l'acquire (socket.writable === true). Recycle après N requêtes ou T secondes pour éviter les sockets longue durée qui peuvent avoir des problèmes silencieux.
🔄 Versions — Node 18 / 20 / 22 / 24
Node 18. TLS 1.3 par défaut (TLS 1.0/1.1 retirés). socket.timeout event correctement déclenché. dgram.Socket supporte addSourceSpecificMembership pour SSM multicast. net.BlockList ajouté (filtre IPv4/IPv6).
Node 20. net.Server.dropMaxConnections permet de refuser de nouvelles connexions une fois atteint un seuil. tls.connect({ ALPNCallback }) ajouté pour décider dynamiquement du protocole négocié côté serveur. Améliorations sur le keep-alive entre redémarrages (timer plus précis).
Node 22. socket.resetAndDestroy() envoie un RST TCP propre (utile pour signaler "abus" à un client). dgram gagne connect() (associer une socket UDP à un peer fixe, mieux pour QUIC user-space). node:net autorise allowHalfOpen plus largement. Le permission model (--allow-net) couvre net, tls, dgram (filtre par host:port).
Node 24. Optimisations du parseur TLS (libssl 3.x, sécurité renforcée). node:net expose Socket.connecting plus tôt dans le cycle de vie. dgram.Socket accepte un signal d'abort pour annuler les send en cours. Permission model stable : --allow-net=10.0.0.0/8 valide CIDR. Support natif des cipher suites post-quantique en TLS (X25519MLKEM768) en preview.
Tableau des changements clés :
| Feature | Node 18 | Node 20 | Node 22 | Node 24 |
|---|---|---|---|---|
| TLS 1.3 par défaut | ✅ | ✅ | ✅ | ✅ |
net.BlockList | ✅ | ✅ | ✅ | ✅ |
socket.resetAndDestroy() | ❌ | ❌ | ✅ | ✅ |
dgram.Socket.connect() | ❌ | ❌ | ✅ | ✅ |
Permission --allow-net=CIDR | ❌ | ❌ | partiel | ✅ |
AbortSignal sur dgram.send | ❌ | ❌ | ❌ | ✅ |
// Node 22+ : reset propre vs end gracieux
socket.resetAndDestroy(); // envoie TCP RST, force fermeture immédiate
socket.end(); // FIN gracieux, attend l'autre côté⚠️ Pitfalls — 6 à 10
- TCP n'est pas message-based. Si tu envoies
write("hello")puiswrite("world"), le lecteur peut recevoir"helloworld","hellow"+"orld", ou tout autre découpage. Sans framing, tes "messages" se mélangent dès qu'il y a charge réseau. Erreur n°1 des débutants. - Pas de gestion de
'error'. Si un socket émeterroret personne n'écoute, le process crashe (uncaughtException). Toujours ajouter au minimumsocket.on('error', () => {}).ECONNRESETpeut arriver à n'importe quel moment (l'autre côté a kill le process). EPIPEsur écriture après close. Si tu écris sur un socket déjà fermé par le peer, tu obtiensEPIPE. Checksocket.writableavant d'écrire dans un code défensif.- Backpressure ignoré → OOM.
for (const x of bigArray) socket.write(serialize(x));sans tester le retour → mémoire process grimpe à plusieurs GiB sous client lent. Pattern correct : pipeline, ou wrapper quiawait drainquandwriteretourne false. - UDP et MTU. Un paquet UDP > MTU (1500 octets typiquement, moins en VPN/IPv6) sera fragmenté par IP, et un seul fragment perdu = paquet entier perdu. Garde tes paquets UDP sous ~1400 octets pour la fiabilité réseau standard.
dgramsocket sansbindne peut pas écouter. Tu peuxsendsansbind, mais pour recevoir, il fautbind. Sur certaines plateformes, bind à port 0 puis envoi attribue dynamiquement un port (utile pour client UDP).- TLS et SNI. Si ton serveur héberge plusieurs domaines TLS, configure
SNICallbackpour fournir le bon cert selonservername. Sans SNI, le client moderne refuse la connexion sur les certs ne matchant pas. - Sockets de domaine Unix et permissions. Par défaut, le socket est créé avec les permissions du process (umask). Pour restreindre,
chmodaprès bind. Le socket reste sur le disque après crash — toujoursunlinkSyncau démarrage. - Half-close vs full-close.
socket.end()ferme le côté write mais laisse le read ouvert (le peer peut encore envoyer).socket.destroy()ferme tout brutalement. Utiliser le mauvais peut tronquer la dernière réponse. - Pool de sockets épuisé. Si tu ouvres des sockets sortants sans fermer (leak), tu épuises les ports éphémères de l'OS (~28k disponibles par défaut). Symptôme :
EADDRNOTAVAILsous charge. Toujourssocket.on('close', ...)pour vérifier les fermetures. - TIME_WAIT accumulation. Côté client, après un close actif, le socket reste en
TIME_WAIT~60 s (RFC 793). Sous benchmarks intensifs avec ouverture/fermeture rapide, tu peux épuiser les ports. Sysctls Linux :net.ipv4.tcp_tw_reuse=1permet la réutilisation pour outbound. Encore mieux : utilise du keep-alive et réutilise les connexions. - Buffer overhead. Chaque socket a un buffer kernel (read + write, ~64 KiB chacun par défaut). 10 000 sockets = ~1.3 GiB de RAM kernel. Sur des serveurs très concurrents (C10M), il faut tuner
net.core.rmem_default/wmem_default.
🔐 TLS approfondi
Sélection des ciphers. TLS 1.3 simplifie : seuls 5 cipher suites (AES-GCM, ChaCha20-Poly1305) sont autorisés, tous parfaitement secure. TLS 1.2 (encore nécessaire pour certains anciens clients) demande un choix : TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 et famille. Évite RC4, 3DES, RSA key exchange (pas de PFS).
mTLS (mutual TLS). Pour de l'auth service-to-service interne, configure requestCert: true, rejectUnauthorized: true, ca: [trustedCertPEM]. Le client présente son cert, tu valides la chaîne ET le subject/CN. Bénéfice : pas de credentials à gérer (le cert EST l'identité), rotation centrale.
SNI dynamique. Pour héberger plusieurs domaines sur un même port :
import { createSecureContext, createServer } from "node:tls";
const contexts = new Map<string, ReturnType<typeof createSecureContext>>();
contexts.set("a.example.com", createSecureContext({ key: keyA, cert: certA }));
contexts.set("b.example.com", createSecureContext({ key: keyB, cert: certB }));
createServer({
SNICallback: (servername, cb) => {
const ctx = contexts.get(servername);
if (ctx) cb(null, ctx);
else cb(new Error("unknown servername"));
},
}, (sock) => {
sock.end("hello");
}).listen(443);Session resumption. TLS 1.3 supporte session tickets et PSK pour des reconnects rapides (0-RTT possible mais avec risque replay). Pour des connexions courtes/fréquentes, économise du handshake (~50-200 ms par connexion).
Performance. Le coût CPU du TLS est dominé par l'asymétrique (handshake initial). En keep-alive, le symétrique (AES-GCM, ChaCha20) bénéficie d'AES-NI sur Intel/AMD et est quasi gratuit. Sur ARM sans crypto extensions, ChaCha20-Poly1305 est plus rapide qu'AES.
🧪 Testing — node --test
// tcp-frame.test.ts
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { createServer, connect, Socket, Server } from "node:net";
import { once } from "node:events";
import { AddressInfo } from "node:net";
let server: Server;
let port: number;
before(async () => {
server = createServer((s) => {
attachReader(s, (msg) => s.write(frame(Buffer.concat([Buffer.from("RE:"), msg]))));
});
server.listen(0);
await once(server, "listening");
port = (server.address() as AddressInfo).port;
});
after(() => new Promise((r) => server.close(() => r(null))));
test("framing simple round-trip", async () => {
const c = connect({ port });
await once(c, "connect");
c.write(frame(Buffer.from("hello")));
const [chunk] = (await once(c, "data")) as [Buffer];
// Lit l'entier de longueur
const len = chunk.readUInt32BE(0);
const payload = chunk.subarray(4, 4 + len);
assert.equal(payload.toString(), "RE:hello");
c.end();
});
test("rejette les frames trop grandes", async () => {
const c = connect({ port });
const badHeader = Buffer.alloc(4);
badHeader.writeUInt32BE(100 * 1024 * 1024, 0); // 100 MiB > cap
c.write(badHeader);
await once(c, "close");
assert.ok(true); // close attendu
});
test("gère ECONNRESET sans crash", async () => {
const c = connect({ port });
await once(c, "connect");
let errSeen: NodeJS.ErrnoException | null = null;
c.on("error", (e) => { errSeen = e as NodeJS.ErrnoException; });
c.resetAndDestroy(); // Node 22+
await once(c, "close");
// Pas d'uncaught exception levée
assert.ok(true);
});// udp-echo.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { createSocket } from "node:dgram";
import { once } from "node:events";
test("UDP echo loopback", async () => {
const server = createSocket("udp4");
server.on("message", (msg, rinfo) => {
server.send(msg, rinfo.port, rinfo.address);
});
server.bind(0);
await once(server, "listening");
const port = server.address().port;
const client = createSocket("udp4");
client.bind(0);
await once(client, "listening");
const received = once(client, "message");
client.send("hello", port, "127.0.0.1");
const [msg] = (await received) as [Buffer];
assert.equal(msg.toString(), "hello");
server.close();
client.close();
});node --test --test-reporter=spec tcp-frame.test.ts udp-echo.test.ts
# Bench du débit TCP local
iperf3 -s &
iperf3 -c 127.0.0.1 -t 10
# Inspection sockets avec ss
ss -tnp state established '( sport = :9000 )'🎬 Cas d'usage concrets
Scénario 1 — Protocole legacy banque socket
Banque encore branchée sur un mainframe AS/400 qui expose un protocole texte custom sur TCP (port 7300, length-prefixed framing : 4 octets BE = longueur, puis payload). Les terminaux de paiement légacy parlent ce protocole, et un service Node moderne sert de gateway entre l'API REST des nouvelles apps mobiles et le mainframe.
net.createConnection({ host, port, keepAlive: true, keepAliveInitialDelay: 30_000 }) avec un parser à état (buffer d'accumulation, lecture du header 4 octets, attente du body complet, émission d'un message). Un seul socket maintenu par worker, multiplexé via un correlationId 8 octets dans le header. Si le mainframe coupe, reconnexion avec backoff (1s, 2s, 4s, max 30s) et toutes les requêtes en vol sont rejetées en 503.
Pattern critique : socket.setTimeout(60_000) avec close sur idle, socket.on('error') qui logge mais ne crash pas (un ECONNRESET du mainframe est fréquent à 3h du matin pendant le backup).
Scénario 2 — IoT industrie capteurs UDP
Usine 4.0 avec 800 capteurs industriels qui poussent leurs mesures en UDP (port 5514, format binaire propriétaire 64 octets). UDP est choisi pour la légèreté (pas de handshake, pas de retransmit, sub-millisecond) — l'application accepte 0.1 % de perte de paquets.
dgram.createSocket({ type: "udp4", reuseAddr: true }) lié sur 0.0.0.0:5514. Chaque message décodé via Buffer.readUInt16BE / readFloatBE pour extraire { sensorId, timestamp, temp, vibration, current }. Push vers Kafka via un producteur batché (10 ms ou 500 messages, premier atteint déclenche le flush). Le serveur tient 50 000 paquets/s sur 1 vCPU avec ~12 % CPU.
Sécurité : pas de chiffrement UDP natif, donc tunneling WireGuard à l'entrée du DC. Anti-spoofing : whitelist des subnets capteurs au niveau firewall, plus un HMAC court (4 octets, key rotative) dans le payload validé applicativement.
Scénario 3 — Logging UDP centralisé (syslog-like)
Plateforme SaaS multi-tenant avec 200 services Node. Logs poussés en UDP vers un collecteur central (port 514 syslog ou format custom), qui dédoublonne, enrichit (tenant, env), forward vers Loki.
Producteurs : chaque service a un client UDP minimal (socket.send(buf, port, host)). Fire-and-forget, pas d'attente de confirmation. Coût marginal d'un log : < 5 µs (vs 50 µs pour un log HTTP). En cas de surcharge, on perd des logs mais l'app continue à servir le trafic critique — un compromis assumé.
Collecteur : dgram server en cluster (3 workers via cluster + SO_REUSEPORT), parsing rapide en streaming, buffer ring en mémoire (capacité 100 k messages, drop oldest), flush HTTP vers Loki par batch de 1000. Le dropRate est exposé dans Prometheus pour alerter si on perd > 1 % des messages.
🛠️ Exemple end-to-end
Serveur TCP avec framing length-prefixed (gateway protocole legacy), reconnexion auto côté client, multiplex via correlationId, et test end-to-end.
import { createConnection, createServer, type Socket } from "node:net";
import { randomBytes } from "node:crypto";
// ---- framing ----
function encodeFrame(correlationId: Buffer, payload: Buffer): Buffer {
if (correlationId.length !== 8) throw new Error("correlationId must be 8 bytes");
const len = Buffer.alloc(4);
len.writeUInt32BE(payload.length + 8);
return Buffer.concat([len, correlationId, payload]);
}
class FrameParser {
private buf = Buffer.alloc(0);
constructor(private onFrame: (cid: Buffer, payload: Buffer) => void) {}
feed(chunk: Buffer) {
this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
while (this.buf.length >= 4) {
const total = this.buf.readUInt32BE(0);
if (this.buf.length < 4 + total) return;
const cid = this.buf.subarray(4, 12);
const payload = this.buf.subarray(12, 4 + total);
this.buf = this.buf.subarray(4 + total);
this.onFrame(Buffer.from(cid), Buffer.from(payload));
}
}
}
// ---- gateway client (Node -> legacy mainframe) ----
class LegacyClient {
private socket: Socket | null = null;
private parser = new FrameParser((cid, payload) => this.dispatch(cid, payload));
private pending = new Map<string, (b: Buffer) => void>();
private connecting: Promise<Socket> | null = null;
constructor(private host: string, private port: number) {}
private async connect(): Promise<Socket> {
if (this.socket && !this.socket.destroyed) return this.socket;
if (this.connecting) return this.connecting;
this.connecting = new Promise((resolve, reject) => {
const s = createConnection({ host: this.host, port: this.port, keepAlive: true });
s.once("connect", () => {
this.socket = s;
this.connecting = null;
s.on("data", (c) => this.parser.feed(c as Buffer));
s.on("close", () => {
this.socket = null;
for (const [, resolve] of this.pending) resolve(Buffer.alloc(0));
this.pending.clear();
});
s.on("error", (err) => console.error("[legacy]", err.message));
resolve(s);
});
s.once("error", (err) => {
this.connecting = null;
reject(err);
});
});
return this.connecting;
}
private dispatch(cid: Buffer, payload: Buffer) {
const key = cid.toString("hex");
const resolver = this.pending.get(key);
if (resolver) {
this.pending.delete(key);
resolver(payload);
}
}
async send(payload: Buffer, timeoutMs = 2000): Promise<Buffer> {
const s = await this.connect();
const cid = randomBytes(8);
const key = cid.toString("hex");
return new Promise((resolve, reject) => {
const t = setTimeout(() => {
this.pending.delete(key);
reject(new Error("legacy timeout"));
}, timeoutMs);
this.pending.set(key, (b) => {
clearTimeout(t);
resolve(b);
});
s.write(encodeFrame(cid, payload));
});
}
}
// ---- demo server pretending to be the mainframe ----
const fakeMainframe = createServer((s) => {
const parser = new FrameParser((cid, payload) => {
const echo = Buffer.concat([Buffer.from("ACK:"), payload]);
s.write(encodeFrame(cid, echo));
});
s.on("data", (c) => parser.feed(c as Buffer));
});
fakeMainframe.listen(7300, async () => {
const client = new LegacyClient("127.0.0.1", 7300);
const reply = await client.send(Buffer.from("BALANCE 1234"));
console.log("reply:", reply.toString());
fakeMainframe.close();
});Points clés : framing length-prefixed pour découper les messages dans le stream TCP, multiplex via correlationId pour pipeliner les requêtes sur un seul socket, reconnexion lazy via connect() qui mémoïse la promise, timeout par requête, propagation propre de la fermeture côté serveur.
🔁 Quand utiliser / éviter
Utilise node:net (TCP brut) quand : tu implémentes un protocole custom (game server, MQTT broker maison, message bus), tu fais un proxy ultra-light, ou tu intégres un protocole binaire existant (Postgres wire protocol, Redis RESP). Tu y gagnes en perf et en contrôle, tu y perds en confort.
Utilise WebSocket ou HTTP/2 plutôt que TCP brut quand : tu pourrais autrement réinventer l'authentification, le framing, le multiplexing, la traversée NAT/proxy. WebSocket te donne un cadre standardisé et marche derrière 99 % des proxies HTTP.
Utilise UDP (dgram) quand : pertes acceptables (métriques DogStatsD, télémetrie OpenTelemetry, video streaming où la frame suivante est plus importante que la précédente), discovery LAN (mDNS), DNS resolver custom, ou tu construis un protocole user-space (QUIC, WebRTC stack).
Évite UDP quand : tu as besoin de fiabilité, d'ordre, ou de transmissions volumineuses (>MTU). Préfère TCP ou un protocole comme QUIC qui gère ces aspects.
Utilise TLS (node:tls) quand : protocole sortant du LAN, ou trafic LAN sensible (mTLS pour service mesh internes). Coût CPU non négligeable sur AES sans accélération matérielle ; vérifie process.versions.openssl et tes ciphers.
Utilise Unix domain sockets quand : IPC strict machine-locale (sidecar Node ↔ Python, app ↔ daemon system). Bénéfice : pas d'overhead réseau, perms filesystem comme contrôle d'accès.
Évite de réinventer la roue. Si tu te retrouves à coder du framing custom + reconnect + heartbeat + auth + flow control, tu réinventes mal MQTT, AMQP ou WebSocket. Évalue d'abord ces briques avant de te lancer en TCP nu.
Évite de mélanger flowing et paused mode sur le même socket. Choisis un mode (socket.on('data') ou socket.read() explicite) et tiens-toi-y. Les bugs subtils de back-pressure viennent souvent du mélange.
🧪 Benchmark et profiling de sockets
Outils essentiels :
ss -tnp(Linux) : liste les sockets TCP avec process associé.lsof -i :PORT: qui écoute sur ce port ?netstat -an(macOS/Windows) : équivalent ss.tcpdump -i any port 9000: capture brute (filtre BPF).wireshark: analyse de captures avec UI.iperf3: mesure de débit pur TCP/UDP.
Mini-bench TCP en Node :
import { createServer, connect } from "node:net";
import { performance } from "node:perf_hooks";
const N = 100_000;
const MSG = Buffer.from("x".repeat(64));
createServer((s) => { s.pipe(s); }).listen(0, async function () {
const port = (this.address() as any).port;
const c = connect({ port });
let recv = 0;
c.on("data", (d) => { recv += d.length; });
await new Promise((r) => c.once("connect", () => r(null)));
const t0 = performance.now();
for (let i = 0; i < N; i++) {
if (!c.write(MSG)) await new Promise((r) => c.once("drain", () => r(null)));
}
c.end();
await new Promise((r) => c.once("close", () => r(null)));
const dt = performance.now() - t0;
console.log(`${N} msg en ${dt.toFixed(0)} ms (${(N / (dt / 1000)).toFixed(0)} msg/s, recv=${recv})`);
});Sur localhost moderne, attends 500k-1M msg/s pour des petits messages. Si tu obtiens beaucoup moins, vérifie : Nagle activé ? Pas de back-pressure géré ? Trop de logs synchrones ?
Profiling avec clinic.js. clinic doctor -- node app.js te dit si ton bottleneck est event loop, CPU, ou I/O. Pour des bugs de back-pressure subtils, c'est inestimable.
🔗 Liens
- Documentation
node:net: https://nodejs.org/api/net.html - Documentation
node:dgram: https://nodejs.org/api/dgram.html - Documentation
node:tls: https://nodejs.org/api/tls.html - RFC 793 (TCP) : https://www.rfc-editor.org/rfc/rfc793
- RFC 768 (UDP) : https://www.rfc-editor.org/rfc/rfc768
- RFC 8446 (TLS 1.3) : https://www.rfc-editor.org/rfc/rfc8446
- Nagle's algorithm explained : https://en.wikipedia.org/wiki/Nagle's_algorithm
- Linux tcp_keepalive sysctls : https://man7.org/linux/man-pages/man7/tcp.7.html
- mDNS / Bonjour : https://www.rfc-editor.org/rfc/rfc6762
🔁 Étude comparative : TCP brut vs WebSocket vs HTTP/2
| Critère | TCP brut (net) | WebSocket | HTTP/2 streams |
|---|---|---|---|
| Overhead par message | Minimal (framing maison) | ~6 octets (header WS) | ~9-20 octets (HEADERS) |
| Traversée NAT/proxies HTTP | Souvent bloqué | Bon (port 443) | Excellent |
| Bidirectionnel | Oui | Oui | Oui (mais semi-async) |
| Auth / cookies | À implémenter | Via upgrade HTTP | Via headers HTTP |
| Multiplexing | À coder | Une connexion = un flux | Streams multiplexés |
| Browser compat | Non | Excellente | Indirect (fetch) |
| Latence (LAN) | < 1 ms | ~1 ms | ~1-2 ms |
| Coût mémoire par connexion | ~16-64 KiB (kernel) | ~32 KiB + lib WS | ~64-128 KiB (stream) |
| Complexité d'impl | Élevée | Moyenne | Faible (frameworks) |
En 2026, le choix par défaut pour de l'I/O réseau custom devrait être :
- HTTP/HTTPS si l'usage est requête-réponse simple → REST +
fetch. - WebSocket si bidirectionnel + browser →
wscôté serveur,WebSocketglobal côté client. - HTTP/2 streams si tu utilises gRPC ou tu fais du push serveur structuré.
- TCP brut uniquement si protocole binaire spécifique, perf critique, ou intégration legacy.
- UDP pour télémetrie/discovery/gaming/QUIC user-space.
🧭 Comment un staff engineer raisonne sur un transport custom
Avant d'écrire une ligne de net.createServer, un ingénieur senior déroule une checklist de décision. La question n'est jamais « comment ouvrir un socket » mais « quelles propriétés du transport je dois garantir, et lesquelles le kernel me donne déjà ».
1. Quel est mon contrat de livraison ? TCP te donne ordre + fiabilité sur un flux, mais pas de frontières de message ni de garantie de bout-en-bout applicatif (un ACK TCP signifie « reçu par le kernel distant », pas « traité par l'app distante »). Si tu as besoin d'un accusé applicatif, tu dois l'ajouter (correlationId + ACK frame). Erreur de junior : croire que socket.write() qui réussit = message traité par le peer.
2. Où vit l'état de framing ? Dès qu'il y a framing, il y a un buffer d'accumulation par connexion. Ce buffer est une surface d'attaque (un attaquant annonce LEN=4GiB et ne renvoie jamais le body → tu accumules ou tu réserves) et une surface de fuite mémoire (si le parser ne libère jamais). Le cap de taille (16 MiB) et le timeout de frame incomplète sont non négociables en prod.
3. Qui contrôle le débit ? Le back-pressure n'est pas un détail de perf, c'est un mécanisme de sûreté. Sans lui, un consommateur lent transforme ton serveur en bombe mémoire. Le tableau mental :
| Symptôme | Cause probable | Diagnostic |
|---|---|---|
| RSS qui grimpe linéairement | write() ignoré (pas de drain) | socket.writableLength élevé |
| Latence en dents de scie | Nagle + Delayed ACK (40 ms) | setNoDelay(true) manquant |
| Connexions zombies qui s'accumulent | pas de heartbeat applicatif | compteur de connexions ouvertes vs actives |
EADDRNOTAVAIL sous charge | épuisement ports éphémères (leak/TIME_WAIT) | ss -s, compteur de sockets |
| CPU à 100 % sans trafic utile | boucle de reconnexion sans backoff | flamegraph → dial() en boucle |
4. Comment ça meurt ? Le mode de défaillance par défaut d'un socket mal géré, c'est le crash du process (error non écouté) ou la fuite silencieuse (socket jamais fermé). Un design senior énumère explicitement : peer qui RST, peer qui disparaît (câble débranché → détecté seulement par heartbeat), peer lent, peer malveillant (frame géante), shutdown propre (drain) vs brutal (RST).
5. Observabilité dès le jour 1. Un transport custom sans métriques est une boîte noire en incident. Minimum vital : nombre de connexions ouvertes (gauge), bytes in/out (counter), frames parsées/rejetées (counter), durée de connexion (histogram), writableLength p99 (détecte le back-pressure avant l'OOM). Sans ça, ton seul outil de debug à 3h du matin c'est tcpdump.
Le réflexe staff : sur 90 % des besoins « j'ai besoin de TCP custom », la bonne réponse est WebSocket, gRPC (HTTP/2) ou un broker (NATS, MQTT, Kafka). Le TCP nu se justifie pour : intégration d'un protocole binaire imposé, contrainte de latence/débit extrême prouvée par un bench, ou contrainte d'absence de dépendance. Si tu réimplémentes framing + reconnect + heartbeat + auth + flow-control, tu réécris mal un protocole qui existe déjà.
🏋️ Exercices
Exercice 1 — Décodeur de framing résistant à la fragmentation arbitraire
Objectif : écrire un FrameParser length-prefixed qui produit exactement la même séquence de messages quelle que soit la découpe des chunks TCP.
Écris un parser, puis un test qui rejoue le même flux d'octets découpé de trois façons : (a) un seul gros chunk, (b) un octet à la fois, (c) des découpes aléatoires. Les trois doivent émettre la même liste de messages. Ajoute le garde-fou LEN > cap → destroy.
Indice/Solution : accumule dans un Buffer, boucle while (buf.length >= 4 && buf.length >= 4 + buf.readUInt32BE(0)). Pour le test, génère un flux complet puis itère sur des tailles de slice ; le parser ne doit jamais supposer qu'un chunk = un message. Le bug typique : lire le header puis oublier de vérifier que le body complet est arrivé.
Exercice 2 — Back-pressure prouvé par la mémoire
Objectif : démontrer empiriquement la différence entre write() naïf et write() respectant le drain.
Serveur qui envoie 1 Go de données à un client qui lit lentement (socket.pause() puis resume() toutes les 500 ms). Version A : boucle while qui ignore le retour de write(). Version B : await drain quand write() retourne false. Mesure process.memoryUsage().rss (ou socket.writableLength) dans les deux cas.
Indice/Solution : version A → RSS explose (le buffer Node bufferise tout en user-space). Version B → RSS plat. Encore mieux : remplace toute la logique par stream.pipeline(source, socket) qui gère le back-pressure automatiquement. Constate que pipeline ≈ version B mais sans bug possible.
Exercice 3 — Proxy TCP transparent avec métriques et shutdown propre
Objectif : un proxy TCP production-grade, pas le one-liner pipe.
Étends le proxy 5 lignes en : (1) comptant bytes up/down par connexion, (2) gérant les deux sens de fermeture sans fuiter de socket (half-close), (3) supportant un server.close() qui draine les connexions existantes avec un timeout de 30 s avant destroy() forcé, (4) exposant /metrics (connexions actives, bytes totaux).
Indice/Solution : garde un Set<Socket> des connexions actives, retire à close. Pour le drain : server.close(cb) arrête l'accept, puis setTimeout(() => activeSockets.forEach(s => s.destroy()), 30_000). Attention au double destroy : guard avec if (!s.destroyed). Le half-close se gère via allowHalfOpen: true et propagation de end (pas destroy) d'un côté à l'autre.
Exercice 4 — Heartbeat applicatif et détection de peer mort
Objectif : détecter un peer disparu (câble débranché) en < 10 s, là où le keep-alive TCP du kernel mettrait 2 h.
Protocole : ping toutes les 5 s, attente d'un pong sous 3 s, sinon destroy(). Teste avec deux process : tue le peer avec SIGKILL (pas de FIN/RST envoyé) et vérifie que l'autre côté détecte la mort via le heartbeat, pas via un event socket.
Indice/Solution : setInterval pour le ping (avec unref()), un setTimeout armé à l'envoi du ping et désarmé à la réception du pong. Le point clé pédagogique : un SIGKILL ne génère AUCUN event TCP côté pair — seul le heartbeat applicatif (ou le keep-alive kernel après des heures) révèle la mort. Compare avec un process.exit() propre qui, lui, envoie un FIN détecté immédiatement par 'close'.
Exercice 5 — Casser puis réparer : amplification UDP et frame géante
Objectif : reproduire deux failles de sécurité réseau classiques, puis les corriger.
(a) Frame géante (TCP) : retire le cap LEN, envoie un header annonçant LEN=2GiB sans body. Observe l'allocation/attente. Répare avec cap + timeout de frame incomplète. (b) Amplification UDP : serveur UDP qui répond plus gros qu'il ne reçoit. Montre comment un attaquant spoofe l'IP source (victime) pour la noyer. Répare avec : limite de taille de réponse ≤ taille requête, et/ou un challenge cookie (handshake léger) avant toute grosse réponse.
Indice/Solution : pour (a), sans cap le Buffer.concat accumule jusqu'à l'OOM ; le fix est le cap + un timer « frame incomplète depuis N s → destroy ». Pour (b), l'amplification est le cœur des DDoS UDP (DNS/NTP/memcached) ; en user-space tu ne peux pas empêcher le spoof IP, donc tu limites le facteur d'amplification (réponse ≤ requête) et tu exiges un round-trip avant d'envoyer du volume — exactement ce que fait le Retry token de QUIC.
Exercice 6 — Pool de connexions sortantes avec validation et recyclage
Objectif : un pool TCP outbound qui ne distribue jamais un socket mort.
Implémente acquire() / release() sur un pool borné (max N sockets vers un upstream). À l'acquire, valide socket.writable && !socket.destroyed ; si invalide, jette et recrée. Recycle après maxUses requêtes ou maxAgeMs. Gère la famine (toutes les connexions prises → file d'attente avec timeout d'acquire).
Indice/Solution : structure = idle: Socket[], inUse: Set, waiters: Array<{resolve, timer}>. À release, si un waiter attend, lui donner le socket directement (sans repasser par idle). Le piège : un socket peut mourir pendant qu'il est idle dans le pool — d'où la validation à l'acquire, pas seulement à la création. Compare ton design avec generic-pool / le pool intégré d'undici.
🎤 En entretien
Q : Pourquoi socket.write() peut retourner false, et que dois-tu faire à ce moment ? R : false signifie que le buffer interne (writableHighWaterMark, 16 KiB par défaut) est plein car le kernel/peer n'absorbe pas assez vite. Tu dois arrêter d'écrire et attendre l'event 'drain' — sinon Node bufferise illimité en mémoire user-space et tu finis en OOM. C'est le mécanisme de back-pressure ; stream.pipeline l'automatise.
Q : Un socket.write() qui réussit garantit-il que le message a été traité par le peer ? R : Non. Il garantit seulement que les octets sont dans le buffer d'envoi du kernel local (ou en transit). TCP ACK = reçu par le kernel distant, pas par l'application distante. Pour une garantie applicative (idempotence, exactly-once), il faut un ACK au niveau protocole (correlationId + frame de confirmation), comme dans le pattern multiplex.
Q : Comment détectes-tu rapidement qu'un peer TCP est mort si le câble est débranché ou le process killé par SIGKILL ? R : Tu ne peux pas compter sur les events socket : un SIGKILL ou une coupure réseau n'envoie ni FIN ni RST. Le keep-alive TCP kernel met ~2 h par défaut (tcp_keepalive_time). La solution prod est un heartbeat applicatif (ping/pong toutes les 5-30 s, timeout court) qui destroy() la connexion dès qu'un pong manque.
Q : Quand préférer un Unix domain socket à TCP loopback (127.0.0.1) pour de l'IPC local, et pourquoi ? R : Quand les deux process sont sur la même machine. Le Unix socket évite toute la stack TCP/IP (pas de checksums, pas de fenêtre de congestion, pas de handshake, pas de TIME_WAIT, pas de ports éphémères consommés), donne une latence sous-ms et un débit supérieur, et offre le contrôle d'accès via permissions filesystem (chmod) plutôt qu'un port ouvert. C'est le défaut de Postgres/MySQL/nginx en local. Le coût : pas de réseau, et il faut gérer le fichier socket résiduel (unlink au démarrage).
Récap final. Travailler en TCP/UDP en Node.js demande trois disciplines : (1) le framing — TCP est un flux d'octets, tu DOIS imposer des frontières (length-prefix ou délimiteurs) ; (2) la gestion du back-pressure — chaque write peut retourner false, l'ignorer, c'est s'exposer à des OOM ; (3) l'exhaustivité du traitement d'erreur — ECONNRESET, EPIPE, ETIMEDOUT, EHOSTUNREACH sont la norme, pas l'exception. UDP a sa place pour les charges où la perte est acceptable et la latence critique. Les sockets de domaine Unix sont le secret du meilleur ratio perf/simplicité pour l'IPC local. Avant de coder ton propre protocole TCP, demande-toi systématiquement si WebSocket, HTTP/2 ou un message broker existant ne couvre pas ton besoin avec moins de risque.