Skip to content

Versions Node 18 → 24 — référence et playbook de migration

TL;DR — En 2026, le paysage Node a quatre versions actives à connaître : Node 18 (sorti d'LTS en avril 2025, dernière vague de patchs critiques), Node 20 (Active LTS jusqu'à avril 2026), Node 22 (LTS principal, le défaut pragmatique), Node 24 (Current depuis 2025). Les jalons majeurs : fetch GA (18), test runner stable (20), permission model (20+), TypeScript natif (22+, par défaut 23.6+), require(ESM) (22), npm 11 et --run (22), V8 perf++ (24). Ce document est une référence : par version, nouveautés, breaking changes, playbook de migration, puis comparaison globale et stratégie LTS.

🧠 Mental model — calendrier LTS

   Node version │ Code name │ Initial   │ Active LTS │ Maintenance │ End-of-Life
   ─────────────┼───────────┼───────────┼────────────┼─────────────┼─────────────
   18           │ Hydrogen  │ Apr 2022  │ Oct 2022   │ Oct 2023    │ Apr 2025  ✓ EOL
   20           │ Iron      │ Apr 2023  │ Oct 2023   │ Oct 2024    │ Apr 2026
   22           │ Jod       │ Apr 2024  │ Oct 2024   │ Oct 2025    │ Apr 2027  ← cible 2026
   24           │ Krypton   │ Apr 2025  │ Oct 2025   │ Oct 2026    │ Apr 2028
   26           │ TBD       │ Apr 2026  │ Oct 2026   │ ...         │ Apr 2029

Règle implicite Node : version paire = LTS (Active 12 mois, Maintenance 18 mois), version impaire = Current uniquement (6 mois). En 2026 :

  • 18 : EOL, ne pas utiliser.
  • 20 : encore Active LTS jusqu'à avril 2026, puis Maintenance. Safe choice mais bientôt vieillissant.
  • 22 : Active LTS, défaut recommandé en 2026.
  • 24 : Current, à adopter quand tu peux tester rapidement.

Node 18 — Hydrogen (référence historique)

Nouveautés majeures

  • fetch GA : fetch, Request, Response, Headers, FormData, Blob natifs. Plus besoin de node-fetch.
  • Web Streams stables : ReadableStream, WritableStream, TransformStream. Interop avec les Web APIs.
  • Test runner expérimental : node --test, API minimaliste, encore instable.
  • Permission model : pas encore (arrive en 20).
  • structuredClone : clone profond natif.
  • OpenSSL 3.0 : nouveau, certaines libs natives à recompiler.
  • --watch flag : expérimental.
js
// fetch natif
const res = await fetch('https://api.example.com/data')
const json = await res.json()

// structuredClone
const deepCopy = structuredClone({ a: { b: { c: 1 } } })

Breaking changes notables

  • OpenSSL 3 plus strict : certains algos legacy ne marchent plus.
  • DNS résolveur change : dns.lookup peut retourner IPv6 par défaut sur certaines configs.

Statut 2026 : EOL

Node 18 a quitté tous les statuts de support en avril 2025. Migrer immédiatement si tu es encore dessus. Les patchs sécurité critiques ne sont plus garantis. Beaucoup de projets npm modernes ne supportent plus 18 (engines.node: ">=20").

Node 20 — Iron

Nouveautés majeures

  • Test runner stable : node:test, sous-tests, hooks, watch mode. API stable, viable pour production.
  • Permission model expérimental : --experimental-permission, contrôle granulaire des accès (fs, net, child_process, worker_threads).
  • Single Executable Applications (SEA) : compile ton app + Node en un seul binaire. Encore expérimental.
  • import.meta.resolve : résolveur d'ESM synchrone.
  • --env-file : charge un .env nativement, sans dotenv.
  • V8 11.3 → 11.8 : 5-10% de perf en plus.
bash
# Permission model
node --experimental-permission --allow-fs-read=/app src/server.js

# .env natif
node --env-file=.env src/server.js
ts
// Test runner stable
import { test } from 'node:test'
import assert from 'node:assert/strict'

test('basic', () => {
  assert.equal(1 + 1, 2)
})

Breaking changes notables

  • fs.Stats constructor deprecated → utilise fs.statSync.
  • Suppression de l'option cwd dans certains fs calls.
  • HTTP : meilleur respect du Connection: close.

Statut 2026

Active LTS jusqu'à avril 2026, puis Maintenance jusqu'en avril 2027. Encore une cible sûre, surtout si tu as une codebase stable qui n'a pas besoin des features 22+. Mais ne démarre pas un nouveau projet sur 20 en 2026 : démarre sur 22.

Node 22 — Jod

Nouveautés majeures

  • require() des modules ESM (stable depuis 22.12) : tu peux enfin charger un package ESM-only depuis du CJS sans import(). Game-changer pour la transition ESM.
  • --experimental-strip-types : exécute du TypeScript directement (strip uniquement, pas de transformation). Devient stable et default-on en 23.6+ pour la syntaxe TS-only.
  • --experimental-transform-types : pour decorators, enums, namespaces.
  • --watch stable : remplace nodemon dans 90% des cas.
  • WebSocket client natif : new WebSocket(url) côté Node, sans ws.
  • --run flag : node --run build exécute npm run build sans le overhead de npm.
  • node:sqlite : SQLite intégré au runtime, expérimental.
  • V8 12.x : Maglev compiler activé, 5-15% de perf.
ts
// require() d'un ESM module (CJS file)
const { z } = require('zod')  // marche même si zod est ESM-only

// TS natif
node --experimental-strip-types src/server.ts

// WebSocket natif
const ws = new WebSocket('wss://api.example.com/stream')
ws.addEventListener('message', (ev) => console.log(ev.data))

// SQLite natif
const { DatabaseSync } = require('node:sqlite')
const db = new DatabaseSync(':memory:')
db.exec('CREATE TABLE t(id INTEGER)')
bash
# --run au lieu de npm run
node --run build           # plus rapide que npm run build
node --watch src/server.ts # auto-restart
node --run test            # alias de package.json scripts

Breaking changes notables

  • fs.realpath.native plus strict sur les liens cassés.
  • --no-warnings ne masque plus les ExperimentalWarning par défaut (utilise --no-experimental-warnings).
  • Suppression définitive de l'punycode module (utilise le package npm).
  • Permission model : API affinée, options renommées (vérifier ta config).

Statut 2026

Active LTS, le défaut recommandé en 2026. EOL planifié pour avril 2027. C'est la version où tout converge : TS natif, ESM mature, perf++, npm 11. Si tu démarres un projet en 2026, vise Node 22.

Node 24 — Krypton

Nouveautés majeures

  • V8 13.x : perf encore en hausse, support amélioré des modules ES en strict mode.
  • --strip-types par défaut : tu peux exécuter du .ts (syntaxe TS-only) sans flag.
  • npm 11 intégré : meilleures perfs install, fix de bugs historiques.
  • Permission model stable (sortie possible du statut expérimental dans une 24.x).
  • WebAssembly : meilleures perfs, support des nouvelles features WASM (GC, threads stables).
  • Diagnostic channel étendu : plus d'événements pour l'observabilité fine.
  • URLPattern global stable : matching d'URLs façon Web exposé en global (new URLPattern({ pathname: '/users/:id' })), sans import. (AbortSignal.any(), lui, existe déjà depuis Node 20.3 / 18.17 — voir matrice.)
ts
// TS sans flag
node src/server.ts          // marche en Node 24+

// AbortSignal.any
const timeoutSig = AbortSignal.timeout(5000)
const userSig = controller.signal
const combinedSig = AbortSignal.any([timeoutSig, userSig])
await fetch(url, { signal: combinedSig })

// URLPattern
const pattern = new URLPattern({ pathname: '/users/:id' })
const m = pattern.exec('https://example.com/users/42')
console.log(m?.pathname.groups.id) // '42'

Breaking changes notables

  • Suppression de certaines APIs deprecated depuis longtemps (vérifier release notes).
  • process.binding retiré (privé depuis longtemps mais encore utilisé par des libs anciennes).
  • Format des permissions ajusté pour la version stable.

Statut 2026

Active LTS depuis octobre 2025 (Current d'avril à octobre 2025), maintenue jusqu'à avril 2028. En juin 2026, Node 24 est une LTS jeune mais éprouvée — sûre pour un greenfield si tu veux la LTS la plus longue (EOL avril 2028 vs avril 2027 pour 22). Tradeoff : 24 a les meilleures perfs et la LTS la plus longue ; 22 a un écosystème de libs natives un cran plus rodé. Pour un nouveau projet en juin 2026, 24 est défendable ; pour une infra critique conservatrice, reste sur 22 encore quelques mois.

⚠️ Piège mental sur le TS natif. En Node 24, --strip-types est on par défaut, mais ça ne fait que retirer les annotations de type (type-stripping). Aucune vérification de types n'est faite à l'exécution : un : number qui reçoit une string passe silencieusement. Le type-check reste à la charge de tsc --noEmit en CI. De plus, les constructs qui émettent du JS (enums const enum, namespaces avec valeurs, decorators legacy, parameter properties) nécessitent --experimental-transform-types et restent expérimentaux. Type-stripping ≠ compilation TypeScript.

Comparaison globale — feature matrix

Feature18202224
fetch natif
Web Streams
node:test stable✗ (exp.)✓ + coverage stable✓ + perf++
Permission modelexp.exp. amélioréstable (24.x)
TS natif (--strip-types)exp. (flag)par défaut
require(ESM)✓ (22.12+)
--watchexp.stablestable
--env-file
--run script
WebSocket client
node:sqliteexp.mature
Single Executableexp.exp. mieuxmeilleur
AbortSignal.any✓ (20.3+)
URLPattern (global)
OpenSSL3.03.0/3.23.x3.x
V810111213
npm91010/1111

Playbook de migration

18 → 20

bash
# 1. Vérifier les engines de tes deps
pnpm outdated
pnpm why <package>  # pour deps cassées

# 2. Mettre à jour les CI et Dockerfile
# Dockerfile :
# - FROM node:18-bookworm-slim
# + FROM node:20-bookworm-slim

# 3. Tester localement
node --version    # v20.x.x
pnpm test
pnpm build

# 4. Migrer node:test si tu utilisais l'expérimental
# (rares breaking changes API)

# 5. Bumper engines dans package.json
# "engines": { "node": ">=20.18.0" }

Risques principaux : libs natives à recompiler, OpenSSL 3.x quirks.

20 → 22

bash
# 1. Audit
pnpm outdated
npx is-node-modern --node-version=22  # check deps compat (illustration)

# 2. Test runner : si tu utilisais Jest avec ESM hacks, c'est le moment de migrer vers Vitest ou node:test

# 3. Si tu utilises TS, considère --experimental-strip-types pour simplifier
# - Remplacer tsx en dev par : node --watch --experimental-strip-types src/server.ts

# 4. Si tu as du code legacy CJS qui charge des libs ESM via dynamic import,
# tu peux maintenant utiliser require() directement (gain de simplicité)

# 5. Dockerfile :
# - FROM node:20-bookworm-slim
# + FROM node:22-bookworm-slim

# 6. CI matrix : tester 20 et 22 en parallèle pendant 2-3 semaines
# Puis dropper 20.

Risques principaux : --no-warnings ne masque plus les ExperimentalWarning, permission model API change.

22 → 24

bash
# 1. Surveille les release notes 24.x pour les breakings.

# 2. Si tu utilises Permission model : vérifie les nouvelles options.

# 3. Si tu utilises certaines APIs deprecated (process.binding, vm sandboxes anciennes),
# elles peuvent être retirées.

# 4. Bonus : enlever `--experimental-strip-types` du CMD si tu exécutes du TS.
# Node 24 le fait par défaut pour la syntaxe TS-only.

# 5. CI matrix 22 + 24 le temps d'avoir confiance.

Risques principaux : peu en théorie (LTS Active), mais comme toujours surveiller les libs natives.

Stratégie générale

  1. Pin engines.node dans package.json pour la version min supportée :

    json
    "engines": { "node": ">=22.0.0 <25.0.0" }
  2. Pin la version exacte via Volta, .nvmrc, ou Docker tag :

    # .nvmrc
    22.11.0
    dockerfile
    FROM node:22.11.0-bookworm-slim
  3. CI matrix sur plusieurs versions pendant les migrations :

    yaml
    strategy:
      matrix:
        node: [22, 24]
  4. Renovate ou Dependabot configurés pour bumper Node automatiquement avec des PR de test.

  5. Tester en staging avant prod, idéalement avec replay de trafic réel.

⚠️ Pitfalls de migration

  • Libs natives non recompilées : node-gyp qui plante au déploiement parce que le Node a changé. Build dans la version cible, pas dans une plus vieille.
  • OpenSSL 3 strictness : algos legacy (RC4, certains MD5 usages) refusés. Met à jour la cryptographie côté code.
  • fs.promises vs callbacks : certaines libs ont changé d'API entre versions. Lis le changelog.
  • node:test API drift : entre 18 expérimental et 20+ stable, des subtilités d'API. Pas un drama, juste un peu de refactor.
  • --experimental-warning masqué silencieusement : tu utilises une feature expérimentale et tu ne le sais plus. Active explicitement les warnings en dev.
  • Permission model qui casse les libs : une lib lit /tmp mais tu as restreint --allow-fs-read=/app. Ne déploie le permission model qu'après audit complet des dépendances.
  • require(ESM) qui surprend : tu refactor un module ESM-only en pensant que les anciennes versions vont l'accepter via require(). Marche seulement en 22.12+. Documente bien tes engines.
  • CI sur Node 18 encore EOL : tu cours après les patchs sécurité. Bloque toi sur 20 minimum.
  • process.platform === 'darwin' pour l'ARM : la détection x86 vs ARM se fait via process.arch. Vérifie tes binaires en CI sur les deux.
  • Versions différentes entre dev/CI/prod : un bug en prod qui ne reproduit pas en dev. Le .nvmrc + Docker tag identiques évitent 90% de ces cas.

🧪 Stratégie de test multi-version

yaml
# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node: [20, 22, 24]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: pnpm
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm test
      - run: pnpm build

Pendant une migration, garde l'ancien Node dans la matrix le temps d'avoir confiance. Une fois confirmé, drop-le pour économiser des minutes CI.

🎬 Cas d'usage concrets

Scénario 1 — Migration legacy SaaS RH "PaySimple" Node 18 → Node 22

PaySimple legacy tourne sur Node 18 LTS depuis sa sortie en 2022. En 2025, Node 18 est en EOL fin avril, et l'équipe doit migrer. Contraintes : 220 routes Express, 4 workers BullMQ, ~ 80 dépendances directes, et zéro downtime acceptable (le SaaS sert 800 PME en continu).

Stratégie sur 5 sprints : (1) sprint 1 : ajouter Node 22 à la matrice CI (strategy.matrix.node: [18, 22]), pour découvrir les incompatibilités avant de toucher la prod ; (2) sprint 2 : fix des 12 warnings/erreurs (notamment des deprecations url.parse qui crashent en Node 22, du code legacy qui dépend de Buffer() sans Buffer.from, et un middleware qui utilise process.binding() retiré) ; (3) sprint 3 : migration des deps qui ne supportent pas Node 22 (node-gyp v9, certaines libs natives) ; (4) sprint 4 : déploiement en staging Node 22 pour 2 semaines, observation des metrics (CPU -7 %, RAM -12 %, latence p99 -8 % grâce au V8 plus récent) ; (5) sprint 5 : rollout canary 10 % → 50 % → 100 % en prod sur 3 jours, avec monitoring serré. Bénéfice : performances mesurables (sans rien refactor), support LTS jusqu'à avril 2027 (Node 22 EOL), et accès aux features Node 22 (--env-file, node:test plus mature, glob natif). Zéro incident pendant la migration.

Scénario 2 — E-commerce "ModeCircuit" Node 20 → Node 22 + strip-types

ModeCircuit tourne sur Node 20 LTS depuis 2024 avec un build TypeScript via tsc qui produit du JS dans dist/ avant exécution. Le pipeline CI prend 4 minutes (install + lint + tsc + test + build). L'équipe veut simplifier et profiter de Node 22 + --experimental-strip-types pour exécuter le TS directement sans étape de transpilation en dev et en CI.

Bénéfices : (1) node --experimental-strip-types src/server.ts lance le code TS sans compile préalable — dev loop plus rapide, pas de dist/ à nettoyer ; (2) en CI, node --experimental-strip-types test/*.test.ts exécute les tests sans tsc ni swc — gain de 45 secondes par run ; (3) le build prod garde tsc pour les .d.ts et swc pour la transpilation (strip-types reste expérimental fin 2026, pas conseillé en prod). Tradeoff : pas de support pour les decorators, enums, namespaces (l'équipe les a déjà bannis depuis 2 ans, donc OK). L'équipe a aussi adopté node --watch (intégré depuis 22) à la place de nodemon, ce qui retire encore une dépendance. Le package.json est plus mince, le dev loop plus snappy, et la CI tourne en 2 min 30 vs 4 min avant.

Scénario 3 — Banque "NeoCrédit" Node 22 → Node 24 prudent

NeoCrédit (la néobanque) tourne sur Node 22 LTS depuis sa sortie. Node 24 (LTS depuis octobre 2025) apporte des features intéressantes : node:sqlite natif (utile pour les tests d'intégration sans Docker), V8 plus récent (perf +5-10 % sur les workloads I/O-light), Web Crypto API plus complète, et import.meta.dirname partout. Mais NeoCrédit étant une banque, ils ne migrent jamais vers une version récente avant qu'elle ne soit éprouvée.

Politique de l'équipe SRE : "attendre 9 mois après la sortie d'une LTS avant migration prod, sauf CVE bloquant". Stratégie : Node 24 testé en staging dès sa sortie (octobre 2025), CI matrix avec [22, 24] mais Node 22 comme version officielle, observation des bug reports et incidents communauté. Migration prod planifiée pour juillet 2026 (9 mois après stable). Avantage de cette approche : ils évitent les bugs cassants des premières releases (Node 24.0.0 avait un bug HTTP/2 qui a été fix en 24.1.2), et bénéficient quand même de Node 24 LTS supporté jusqu'en avril 2028. Le code applicatif n'utilise pas encore node:sqlite ni les nouveautés Node 24, mais le bump permettra de les adopter au cas par cas.

🛠️ Exemple end-to-end

Cas d'usage : "migration PaySimple legacy Node 18 → Node 22 — Dockerfile, CI matrix, code legacy refactor, et observabilité du déploiement".

dockerfile
# Avant — Node 18
FROM node:18-bookworm-slim
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile --prod
COPY dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

# Après — Node 22 distroless multi-stage
FROM node:22-bookworm AS builder
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
COPY . .
RUN pnpm build && pnpm prune --prod

FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /app/dist ./dist
COPY --from=builder --chown=nonroot:nonroot /app/node_modules ./node_modules
USER nonroot
ENV NODE_ENV=production NODE_OPTIONS="--enable-source-maps"
EXPOSE 3000
CMD ["dist/server.js"]
yaml
# .github/workflows/ci.yml — pendant la migration
name: ci
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node: [18, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm test
      - run: pnpm build
      - name: Smoke check imports
        run: node -e "require('./dist/server.js'); process.exit(0)"
ts
// src/legacy/refactor-node22.ts
// Avant — code Node 18 cassé en Node 22
import url from 'node:url'
import { Buffer } from 'node:buffer'

export function parseLegacyUrl(input: string) {
  return url.parse(input)
}

export function rawBufferOld(data: string) {
  return new Buffer(data)
}

// Après — code Node 22 compatible
export function parseLegacyUrlV2(input: string) {
  return new URL(input)
}

export function rawBufferNew(data: string) {
  return Buffer.from(data, 'utf8')
}

// Détection runtime de la version Node pour gérer une période de transition
const [major] = process.versions.node.split('.').map(Number)
if (major < 22) {
  console.warn(`[deprecation] running on Node ${process.versions.node}, please upgrade to Node 22 LTS by April 2025`)
}
ts
// src/observability/version-metric.ts
// Métrique exportée pour suivre le rollout multi-version en prod.
// On utilise un ObservableGauge : la valeur (toujours 1 par pod, labellisée par
// version) est ré-observée à chaque scrape, donc un pod qui meurt arrête de
// publier sa série — contrairement à un Counter qui resterait collé.
import { metrics } from '@opentelemetry/api'
const meter = metrics.getMeter('paysimple-runtime')

const [nodeMajor] = process.versions.node.split('.').map(Number)
const labels = {
  major: String(nodeMajor),
  full: process.versions.node,
  v8: process.versions.v8,
}

meter
  .createObservableGauge('runtime.node.version_active', {
    description: 'Active Node.js major version on this instance (1 per pod)',
  })
  .addCallback((result) => result.observe(1, labels))

// Dashboard Grafana : sum(runtime_node_version_active) by (major)
// → on voit en direct le pourcentage de pods en Node 18 vs Node 22 pendant le rollout.
// Alerte : si sum(...) by (major="18") > 0 après J+3 du canary, un pod est resté
// en arrière → investiguer (DaemonSet figé, image mal taggée, rollback partiel).
yaml
# k8s/canary-rollout.yaml — pendant le rollout Node 18 → 22
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata: { name: api-core }
spec:
  replicas: 20
  strategy:
    canary:
      maxSurge: 2
      maxUnavailable: 0
      steps:
        - setWeight: 10
        - pause: { duration: 30m }
        - setWeight: 25
        - pause: { duration: 1h }
        - setWeight: 50
        - pause: { duration: 2h }
        - setWeight: 100
      analysis:
        templates: [{ templateName: error-rate-and-latency }]
        startingStep: 1
        args:
          - { name: service-name, value: api-core }
  template:
    spec:
      containers:
        - name: app
          image: registry.paysimple.io/api-core:node22-${SHA}

Cet exemple couvre la migration end-to-end de PaySimple Node 18 → Node 22 : (1) Dockerfile passé de node:18-bookworm-slim simple à un multi-stage distroless Node 22 (image -85 %, attack surface réduite), (2) CI matrix avec [18, 22] qui garde Node 18 le temps de la transition (sécurité : si un dev casse Node 22, Node 18 reste vert et l'équipe peut investiguer sans pression), (3) refactor du code legacy qui utilise des APIs deprecated/retirées en Node 22 (url.parsenew URL, new Buffer()Buffer.from), avec un warning runtime pendant la période de transition, (4) métrique OTel runtime.node.version_active qui permet de visualiser sur Grafana le pourcentage de pods en Node 18 vs Node 22 pendant le rollout canary, (5) Argo Rollouts avec canary 10 → 25 → 50 → 100 % et analysis automatique (erreur rate + latency, rollback automatique si dégradation). Bénéfice mesuré sur PaySimple : -7 % CPU, -12 % RAM, -8 % latence p99, et zéro incident pendant les 3 jours de canary. Le V8 de Node 22 est mesurablement plus efficace sur les workloads I/O-light typiques d'un backend Node.


🔁 Quand utiliser quoi

CasVersion recommandée
Nouveau projet en 2026Node 22 (LTS le plus long restant)
App critique très stableNode 22 LTS (rester sur LTS, jamais Current)
Veille techno / projet jouetNode 24 (Current, latest features)
Lib publiée sur npmSupport Node 20+ et Node 22+ (le 18 est EOL)
Lambda / serverlessSuivre les versions supportées par le provider (souvent légèrement en retard)
Containers en prodNode 22 LTS, image node:22-bookworm-slim ou distroless 22

🛠️ Features par version — exemples pratiques

Node 20 — --env-file natif

Plus besoin de dotenv pour les cas simples :

bash
node --env-file=.env --env-file=.env.local src/server.ts

Les fichiers sont chargés dans l'ordre, le suivant override le précédent. Format identique à dotenv (KEY=value, lignes commentées avec #).

Node 22 — --watch stable

Remplace nodemon pour 90% des cas :

bash
node --watch src/server.ts
node --watch --watch-path=src --watch-path=config src/server.ts

Plus rapide qu'nodemon, intégré, zero-dep. Limite : pas de filtrage avancé par glob comme nodemon.

Node 22 — --run

Lance des scripts npm sans le overhead de npm run :

bash
node --run build           # équivalent de npm run build, mais ~10× plus rapide
node --run -- test --watch # args supplémentaires

Le gain vient du fait que npm fait beaucoup de choses (résolution paths, hooks, lifecycle scripts) que --run skip.

Node 22 — require(ESM)

Avant 22.12, ce code échouait :

js
// app.cjs (CommonJS)
const zod = require('zod')   // ERR_REQUIRE_ESM si zod est ESM-only

Depuis 22.12+ :

js
const zod = require('zod')   // marche ! sync require d'un module ESM

C'est un game-changer pour les codebases CJS qui doivent consommer des libs ESM-only modernes. Restrictions : le module ESM ne peut pas avoir de top-level await dans le graphe d'imports.

Node 22 — node:sqlite

SQLite intégré, parfait pour tests d'intégration, prototypes, cache local.

ts
import { DatabaseSync } from 'node:sqlite'

const db = new DatabaseSync(':memory:')
db.exec(`
  CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT NOT NULL);
`)

const insert = db.prepare('INSERT INTO users VALUES (?, ?)')
insert.run('u-1', 'Alice')

const get = db.prepare('SELECT * FROM users WHERE id = ?')
console.log(get.get('u-1'))  // { id: 'u-1', name: 'Alice' }

Encore expérimental en 22, plus mature en 24. Ne remplace pas Postgres mais utile pour des cas légers.

Node 22 — WebSocket client

ts
const ws = new WebSocket('wss://stream.example.com/feed')

ws.addEventListener('open', () => {
  ws.send(JSON.stringify({ type: 'subscribe', channel: 'orders' }))
})

ws.addEventListener('message', (ev) => {
  console.log('message:', ev.data)
})

ws.addEventListener('error', (err) => {
  console.error(err)
})

Plus besoin de la lib ws pour les use cases client. Compatible avec l'API Web standard.

Node 24 — URLPattern

Router-like pour matcher des URLs sans framework :

ts
const pattern = new URLPattern({ pathname: '/users/:userId/posts/:postId' })

const match = pattern.exec('https://example.com/users/42/posts/7')
console.log(match?.pathname.groups)  // { userId: '42', postId: '7' }

Utile pour des routers légers ou des Edge functions.

AbortSignal.any() (depuis Node 20.3 / 18.17)

Compose plusieurs signals (timeout, user cancel, parent context). Disponible bien avant Node 24 (landed en 20.3.0 et 18.17.0), mais souvent méconnu :

ts
const timeoutSignal = AbortSignal.timeout(5000)
const userSignal = req.signal                    // ex: Express req.signal
const combined = AbortSignal.any([timeoutSignal, userSignal])

await fetch(url, { signal: combined })

Sur un runtime très ancien (Node 18 < 18.17), il fallait composer manuellement avec un AbortController enchaîné — d'où le polyfill de l'Exercice 3. Plus simple, moins de bugs.

🛠️ Stratégie de tests sur multi-versions

js
// vitest.config.ts — détection conditionnelle
import { defineConfig } from 'vitest/config'

const nodeVersion = Number(process.versions.node.split('.')[0])

export default defineConfig({
  test: {
    exclude: [
      ...(nodeVersion < 22 ? ['**/sqlite.test.ts', '**/websocket.test.ts'] : []),
      ...(nodeVersion < 24 ? ['**/urlpattern.test.ts'] : []),
    ],
  },
})
ts
// Dans un test
import { test } from 'node:test'

const skip = Number(process.versions.node.split('.')[0]) < 22

test('require(ESM) works', { skip }, () => {
  const z = require('zod')
  // ...
})

🛠️ Compat package.json

Les hints pour les consommateurs :

json
{
  "name": "my-lib",
  "version": "2.0.0",
  "engines": {
    "node": ">=20.18.0"
  },
  "engineStrict": true
}

Quand un user installe avec un Node trop ancien, npm/pnpm émet un warning (ou échoue avec engineStrict). C'est ta façon de dire "je supporte 20+".

Pour une lib publiée, viser deux LTS actives : actuellement 20 + 22. Quand 20 sort de LTS (avril 2026), passer à 22 + 24.

🔗 Liens

🧭 Comment un staff engineer choisit une version

La question "quelle version Node ?" n'est presque jamais "laquelle a les features les plus récentes". C'est un arbitrage entre fenêtre de support, surface de risque, et coût de la prochaine migration. Le modèle mental :

DimensionQuestion à se poserSignal de décision
Fenêtre de supportCombien de mois de patchs sécurité me reste-t-il ?Ne jamais descendre sous ~6 mois de runway avant EOL
Maturité écosystèmeMes libs natives (node-gyp, sharp, better-sqlite3, drivers) ont-elles des prebuilds pour cette version ?Si non → recompilation forcée, CI plus lente, risque ABI
Coût de migrationCombien de breaking changes touchent mon code ?Audité via CI matrix avant de décider, pas après
Bénéfice mesurableLe gain perf/feature justifie-t-il le risque ?Mesurer en staging, pas supposer
Politique d'orgAi-je une contrainte SRE (ex: "LTS éprouvée +N mois") ?Override technique par la gouvernance

Heuristique de décision (juin 2026) :

  • Greenfield, équipe à l'aise → Node 24 LTS (runway le plus long, EOL avril 2028).
  • Greenfield, org conservatrice → Node 22 LTS (ultra-rodé, EOL avril 2027).
  • Brownfield critique sur 20 → migrer vers 22 maintenant (20 passe Maintenance en avril 2026, runway qui se ferme), pas vers 24 directement sauf besoin.
  • Lib npm publique → supporter les deux LTS Active (22 + 24 en juin 2026), engines à >=22.
  • Encore sur 18 → urgence sécurité, c'est EOL depuis avril 2025.

Le piège classique du dev qui devient senior : optimiser pour les features (node:sqlite, URLPattern) au lieu d'optimiser pour la réduction du risque opérationnel. Un staff engineer migre pour rester dans la fenêtre de support et capturer la perf "gratuite" du V8 plus récent — les features cool sont un bonus, pas le driver.

Failure modes à connaître en production

  • Permission model qui casse en cascade. --allow-fs-read=/app semble propre, mais une lib lit /etc/ssl/certs, le runtime lit /tmp pour les sockets, et node:sqlite écrit son WAL ailleurs. Symptôme : ERR_ACCESS_DENIED au runtime, jamais au démarrage. Mitigation : activer en audit mode d'abord (logger les accès sans bloquer), puis durcir. Ne jamais déployer le permission model sans avoir tracé tous les accès fs/net/child_process de l'arbre de deps.
  • require(ESM) qui marche en dev et casse en prod. Marche depuis 22.12 — mais uniquement si le graphe ESM importé n'a aucun top-level await. Une lib qui ajoute un TLA dans une mineure casse ton require() du jour au lendemain avec ERR_REQUIRE_ASYNC_MODULE. Pin tes versions et teste l'import en CI (node -e "require('...')").
  • ABI mismatch des addons natifs. Le numéro ABI (process.versions.modules) change entre majeures. Un node_modules buildé sur 22 (ABI 127) chargé sur 24 (ABI 137) → Error: was compiled against a different Node.js version. C'est la cause n°1 de "ça marche sur ma machine". Toujours npm rebuild / réinstaller dans l'image de la version cible, jamais copier un node_modules cross-version.
  • Type-stripping qui masque une erreur de type en prod. node --strip-types n'exécute aucune vérif de type. Un bug de type qui aurait été attrapé par tsc passe en prod. Garde un tsc --noEmit bloquant en CI même si tu exécutes le TS nativement.
  • Drift dev/CI/prod silencieux. .nvmrc à 22, Dockerfile à node:22-bookworm-slim, mais l'image base a bumpé vers 22.13 alors que le dev est sur 22.5. Un bug V8 spécifique à une mineure ne reproduit pas. Pin la version exacte (22.13.0) partout, pas juste la majeure.

🏋️ Exercices

Exercice 1 — Détecteur de compatibilité de version (implement)

Objectif : écrire un module assertNodeRuntime() qui valide au boot que le runtime supporte les features dont l'app dépend, et échoue fast avec un message actionnable.

Contraintes : prendre une liste de features requises (['fetch', 'requireESM', 'sqlite', 'urlPattern', 'env-file']), mapper chacune à une version minimale, et lever une erreur claire si process.versions.node est en dessous. Bonus : distinguer "feature absente" de "feature expérimentale derrière un flag".

Indice/Solution : un objet { feature: minMajor } ; parser process.versions.node, comparer la majeure. Pour les flags expérimentaux, sonder process.execArgv ou tenter un require('node:sqlite') dans un try/catch (l'absence du flag jette ERR_UNKNOWN_BUILTIN_MODULE ou similaire). Échouer avec process.exitCode = 1 + message listant feature → version → version courante.

Exercice 2 — CI matrix qui drop automatiquement les versions EOL (production-grade)

Objectif : générer dynamiquement la matrice Node de la CI à partir du calendrier officiel, pour ne jamais tester une version EOL ni oublier d'ajouter une nouvelle LTS.

Contraintes : un script Node (node:test + native fetch) qui fetch https://raw.githubusercontent.com/nodejs/Release/main/schedule.json, filtre les versions dont end > today et lts est défini, et émet un matrix.json consommé par fromJSON() dans le workflow. Tester le script avec une date figée.

Indice/Solution : fetch le JSON, Object.entries, filtrer sur Date.parse(meta.end) > Date.now() et meta.lts. Pour la testabilité, injecter now en paramètre. Côté GitHub Actions : un job setup qui produit outputs.matrix, consommé par un strategy.matrix.node via fromJSON(needs.setup.outputs.matrix) (voir le bloc YAML ci-dessous). Edge case : inclure la Current (impaire) seulement si INCLUDE_CURRENT=true.

yaml
# .github/workflows/ci.yml — matrice générée dynamiquement
jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.gen.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
      - id: gen
        run: echo "matrix=$(node scripts/node-matrix.mjs)" >> "$GITHUB_OUTPUT"
  test:
    needs: setup
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node: ${{ fromJSON(needs.setup.outputs.matrix) }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "${{ matrix.node }}" }
      - run: corepack enable && pnpm install --frozen-lockfile && pnpm test

Exercice 3 — AbortSignal.any avec polyfill conditionnel (implement + compat)

Objectif : écrire un helper combineSignals(...signals) qui utilise AbortSignal.any() nativement quand il existe (Node 20.3+ / 18.17+), et tombe sur un polyfill propre sur les runtimes plus anciens, avec la même sémantique (abort dès qu'un signal abort, propagation du reason). Le helper doit détecter la capacité par feature-test, pas par numéro de version.

Contraintes : pas de fuite mémoire (retirer les listeners quand le combiné abort), propager le reason du premier signal déclencheur, gérer le cas où un signal est déjà aborted à l'entrée.

Indice/Solution : if (typeof AbortSignal.any === 'function') return AbortSignal.any(signals). Sinon créer un AbortController, pour chaque signal : si déjà aborted → abort immédiat avec son reason ; sinon addEventListener('abort', onAbort, { once: true }). Dans onAbort, appeler ctrl.abort(sig.reason) et retirer tous les autres listeners. Tester la fuite avec controller.signal.listenerCount (ou un compteur manuel).

Exercice 4 — Reproduire et fixer un ABI mismatch (break-then-fix)

Objectif : provoquer délibérément l'erreur was compiled against a different Node.js version, comprendre la cause, puis durcir le pipeline pour l'empêcher.

Contraintes : installer une dep native (ex: better-sqlite3) sous Node 22, puis lancer sous Node 24 sans rebuild → observer le crash. Ensuite, corriger : (a) au niveau image Docker (rebuild dans la version cible), (b) au niveau garde-fou (script qui compare process.versions.modules attendu vs réel au boot).

Indice/Solution : la cause est process.versions.modules (numéro ABI) qui diffère entre majeures. Fix Docker : multi-stage où npm ci tourne dans la même image base que le runtime. Garde-fou : stocker l'ABI attendu dans un fichier au build, le comparer au boot et refuser de démarrer si mismatch (fail fast > crash obscur au premier require natif). Leçon : ne jamais COPY un node_modules cross-version.

Exercice 5 — Permission model en audit-then-enforce (production-grade, hard)

Objectif : déployer le permission model sur une vraie app Express + une dep native sans casser la prod, en passant par une phase d'audit.

Contraintes : (1) tracer tous les accès fs/net/child_process réels de l'app (pas juste les tiens — ceux des deps) ; (2) en déduire le set minimal de --allow-* ; (3) démontrer qu'un retrait d'un --allow-fs-read casse un chemin précis ; (4) écrire un test d'intégration qui boot l'app avec les permissions durcies et vérifie qu'elle sert encore une requête.

Indice/Solution : phase audit = lancer avec --trace-permission (ou wrapper les fs/net via --require d'un module qui logge les chemins) sur un replay de trafic représentatif. Construire le --allow-fs-read/--allow-fs-write/--allow-net minimal. Le test break : retirer le --allow-fs-read du dossier des certs → la requête HTTPS sortante jette ERR_ACCESS_DENIED. Le test enforce : spawn node --permission --allow-fs-read=... server.js et fetch un endpoint. Leçon staff : audit avant enforce, sinon on découvre les accès manquants en incident prod.

🎤 En entretien

Q : Pourquoi node --strip-types ne remplace pas tsc dans ta CI ? Parce que le type-stripping retire les annotations sans vérifier les types — un bug de type passe à l'exécution. tsc --noEmit reste nécessaire comme étape de validation bloquante ; strip-types n'est qu'un loader d'exécution, pas un type-checker.

Q : require() d'un module ESM marche depuis Node 22.12. Quelle est la limite qui peut te casser en prod ? Le top-level await : si le graphe ESM importé contient un TLA, require() jette ERR_REQUIRE_ASYNC_MODULE. Une lib peut introduire un TLA dans une mineure et casser ton import du jour au lendemain — d'où l'intérêt de pinner et de tester l'import en CI.

Q : Tu as Node 18 en prod en 2026 et "ça marche". Quel est l'argument pour migrer immédiatement ? 18 est EOL depuis avril 2025 : plus aucun patch de sécurité garanti. Une CVE runtime sans patch upstream te laisse sans remédiation propre. Le "ça marche" ne couvre pas le risque sécurité — la migration est un impératif de compliance/risk, pas une optimisation.

Q : Comment migres-tu une majeure Node sans downtime sur un service avec deps natives ? CI matrix [ancienne, nouvelle] pour découvrir les breakings avant la prod ; rebuild des addons natifs dans l'image cible (jamais copier node_modules cross-ABI) ; staging avec replay de trafic réel et observation des métriques (CPU/RAM/p99) ; rollout canary (10→25→50→100 %) avec analysis automatique et rollback sur dégradation ; métrique de version par pod pour visualiser le mix pendant le rollout.

🗓️ Récap final

En 2026, le choix est simple : Node 22 en prod, Node 24 en veille. Node 20 reste valide jusqu'à avril 2026 puis bascule Maintenance — utile pour ne pas casser une infra figée, mais pas une cible pour démarrer. Node 18 est mort, point. Les jalons à retenir : fetch (18), test runner stable (20), permission model (20+), require(ESM) et TS natif (22), URLPattern global (24). (AbortSignal.any() est plus ancien — 20.3+ — souvent cru "nouveau".) La règle de discipline qui ne change jamais : .nvmrc + engines + Docker tag identiques entre dev, CI et prod, CI matrix sur deux versions pendant les migrations, et bouge dès qu'une LTS approche EOL — ne reste pas sur un Node EOL sous prétexte que "ça marche".

Bibliothèque tech perso — Achref