Skip to content

Express 5 — Deep Dive senior

TL;DR — Express 5 (sorti officiellement en octobre 2024, stable et largement adopté en 2026) corrige enfin le talon d'Achille historique du framework : la gestion des erreurs asynchrones. Une async function qui throw est maintenant capturée nativement par le routeur, sans try/catch ni wrapper express-async-handler. Le reste reste fidèle à l'esprit Express : middleware en pipeline, signature explicite (req, res, next), écosystème massif (Passport, body-parser, multer, helmet, csurf). Express est plus lent que Fastify et plus verbeux que Hono, mais en 2026 il reste le choix par défaut pour : (1) les stacks legacy qu'on ne va pas migrer, (2) les équipes qui veulent un framework "boring" sans magie, (3) les apps où la performance brute n'est pas le goulot. Cette page couvre le pipeline middleware, le nouveau handler d'erreur natif, les patterns seniors (auth, validation, error envelope), la perf comparée, et la migration 4 → 5.

🧠 Mental model — ASCII + analogie

Express est un tuyau d'usine où la requête traverse une suite de stations (middlewares). Chaque station peut :

  1. Lire/modifier la requête (req).
  2. Lire/modifier la réponse (res).
  3. Passer la main à la station suivante (next()).
  4. Court-circuiter en envoyant une réponse (res.send, res.json, res.end).
  5. Signaler une erreur (next(err)) qui shortcut vers le pipeline d'erreur.
                 ┌───────────────────────────────────────────────┐
HTTP request ───▶│ app.use(cors)                                 │
                 │     │                                         │
                 │     ▼                                         │
                 │ app.use(express.json)        ← parse body     │
                 │     │                                         │
                 │     ▼                                         │
                 │ app.use(logger)              ← log            │
                 │     │                                         │
                 │     ▼                                         │
                 │ app.use('/api', apiRouter)   ← scoped         │
                 │     │      │                                  │
                 │     │      ├─ router.use(auth)                │
                 │     │      ├─ router.get('/users', handler)   │
                 │     │      └─ router.post('/orders', val, h)  │
                 │     ▼                                         │
                 │ app.use(notFound)            ← 404            │
                 │     │                                         │
                 │     ▼                                         │
                 │ app.use(errorHandler)        ← signature (e,req,res,next)
                 └───────────────────────────────────────────────┘


                                       HTTP response

Analogie : Express est une chaîne de production Toyota. Chaque ouvrier (middleware) fait une tâche précise puis appelle le suivant. Si un ouvrier tire le cordon Andon (next(err)), la ligne saute directement au poste de réparation (error middleware). En Express 5, le robot async ne se bloque plus en silence : s'il casse, le cordon est tiré automatiquement.

L'autre métaphore utile : Express est un routeur Cisco minimaliste. Tu lui donnes un ordre de règles, il les applique dans l'ordre, top-to-bottom. Si tu inverses deux middlewares, le comportement change. C'est puissant et dangereux.

🛠️ Code minimal (ts)

ts
// app.ts
import express, { type Request, type Response, type NextFunction } from 'express'
import { z } from 'zod'

const app = express()

// 1) Parsers globaux
app.use(express.json({ limit: '1mb' }))
app.use(express.urlencoded({ extended: false }))

// 2) Logger maison (avant tout le reste qui dépend du timing)
//    NB : on garde le param `res` (pas `_res`) car on s'y abonne via res.on('finish').
app.use((req, res, next) => {
  const start = process.hrtime.bigint()
  res.on('finish', () => {
    const ms = Number(process.hrtime.bigint() - start) / 1e6
    console.log(`${req.method} ${req.url} ${res.statusCode} ${ms.toFixed(1)}ms`)
  })
  next()
})

// 3) Auth middleware — exemple JWT minimal
function requireAuth(req: Request, _res: Response, next: NextFunction) {
  const token = req.headers.authorization?.replace(/^Bearer /, '')
  if (!token) return next(new HttpError(401, 'missing token'))
  try {
    req.user = verifyJwt(token)
    next()
  } catch {
    next(new HttpError(401, 'invalid token'))
  }
}

// 4) Validator générique basé sur Zod
function validate<T>(schema: z.ZodType<T>) {
  return (req: Request, _res: Response, next: NextFunction) => {
    const parsed = schema.safeParse(req.body)
    if (!parsed.success) return next(new HttpError(400, 'validation', parsed.error.issues))
    req.body = parsed.data
    next()
  }
}

// 5) Route async — en Express 5, plus besoin de wrapper
app.post(
  '/users',
  validate(z.object({ email: z.string().email(), name: z.string().min(1) })),
  async (req, res) => {
    const user = await db.users.insert(req.body) // throw → next(err) automatique
    res.status(201).json({ data: user })
  }
)

// 6) 404
app.use((_req, _res, next) => next(new HttpError(404, 'route not found')))

// 7) Error envelope — 4 args, sinon Express ne le reconnaît pas
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
  if (err instanceof HttpError) {
    return res.status(err.status).json({ error: { message: err.message, details: err.details } })
  }
  console.error(err)
  res.status(500).json({ error: { message: 'internal' } })
})

class HttpError extends Error {
  constructor(public status: number, message: string, public details?: unknown) { super(message) }
}

app.listen(3000)

Quelques remarques clés sur ce squelette :

  • L'ordre des app.use est sémantique. Le logger avant les parsers loggerait sans le body parsé. Les parsers avant l'auth permettent à l'auth de lire req.body. L'error handler doit être enregistré en dernier sinon il n'attrape rien.
  • La signature à 4 arguments (err, req, res, next) est ce qui dit à Express : "ceci est un error middleware". Si tu écris (req, res, next), Express le traite comme un middleware normal et il ne reçoit jamais l'erreur.
  • En Express 5, les fonctions async qui throw passent automatiquement par next(err). En Express 4, il fallait wrapper avec express-async-handler ou monkey-patch le routeur.

Typer req.user proprement (declaration merging)

Le squelette ci-dessus écrit req.user = verifyJwt(token). En TypeScript strict, req.user n'existe pas sur le type Request d'Express — il faut augmenter l'interface via declaration merging. C'est le pattern canonique, supérieur au (req as any).user qui détruit la sûreté de type partout en aval :

ts
// types/express.d.ts — chargé via "include" dans tsconfig
import 'express'

declare global {
  namespace Express {
    interface Request {
      user?: { id: string; roles: readonly string[]; stripeCustomerId?: string }
    }
  }
}
export {}

Subtilité de fond : req.user reste optionnel (?) au niveau du type, car toutes les routes ne passent pas par requireAuth. Dans un handler protégé, on assume sa présence avec req.user! — c'est un endroit légitime pour le non-null assertion uniquement parce que le middleware d'auth garantit l'invariant en amont. Un staff engineer documente cet invariant (commentaire ou un type AuthedRequest = Request & { user: NonNullable<Request['user']> }) plutôt que de le laisser implicite.

Limite du modèle : Express n'a pas de système de "request scope" typé par route comme Fastify (FastifyRequest génériquement paramétré) ou NestJS (guards qui enrichissent un contexte typé). Avec Express, l'enrichissement de req est global et non vérifié par le compilateur : rien ne t'empêche de lire req.user dans une route sans auth. C'est le coût de la simplicité du framework, et la raison n°1 pour laquelle les grosses équipes finissent par migrer vers Nest.

🎯 Patterns courants

Pattern 1 — Auth middleware composable

L'auth en Express n'est pas un objet "guard" comme NestJS, c'est un middleware. La règle senior : un seul middleware par responsabilité, et on les compose.

ts
const requireAuth = (req, _res, next) => { /* vérifie JWT */ }
const requireRole = (role: string) => (req, _res, next) => {
  if (!req.user?.roles.includes(role)) return next(new HttpError(403))
  next()
}
const rateLimitPerUser = rateLimit({ keyGenerator: req => req.user?.id ?? req.ip })

app.get('/admin/dashboard', requireAuth, requireRole('admin'), rateLimitPerUser, handler)

Ce style fonctionne tant que les middlewares restent purs (pas d'effets de bord cachés). Dès qu'on a un graphe de dépendances complexe (logger qui dépend du user qui dépend de la session), on passe à NestJS ou Fastify avec scopes.

Pattern 2 — Request validator

Express n'a pas de validation native. Trois choix mainstream en 2026 :

  1. Zod (le plus populaire) — schemas TypeScript-first, inférence de types automatique.
  2. Joi — ancien standard, toujours maintenu mais pas TS-first.
  3. express-validator — API à base de chaînes (body('email').isEmail()), pas typé.

Le wrapper Zod ci-dessus (validate(schema)) gagne 90 % des cas. On peut l'étendre pour valider params et query :

ts
function validate<B, Q, P>(opts: {
  body?: z.ZodType<B>; query?: z.ZodType<Q>; params?: z.ZodType<P>
}) {
  return (req, _res, next) => {
    for (const key of ['body', 'query', 'params'] as const) {
      const schema = opts[key]
      if (!schema) continue
      const result = schema.safeParse(req[key])
      if (!result.success) return next(new HttpError(400, `invalid ${key}`, result.error.issues))
      ;(req as any)[key] = result.data
    }
    next()
  }
}

Pattern 3 — Error envelope unifié

L'erreur que tu renvoies au client doit toujours avoir la même shape, sinon ton frontend devient une checklist de cas spéciaux. Le contrat senior :

json
{ "error": { "code": "VALIDATION", "message": "email invalid", "details": [...] } }

Le code de l'error handler central traduit toutes les erreurs internes (Zod, MongoDuplicateKey, JWT expired, etc.) en cette enveloppe. C'est le seul endroit du codebase où on touche res.status pour les 4xx/5xx.

Pattern 4 — Routers modulaires

Router() est un mini-app embarquée. On l'utilise pour scope les middlewares :

ts
const apiRouter = express.Router()
apiRouter.use(requireAuth) // tous les sous-routes auth
apiRouter.get('/me', handler)
apiRouter.use('/orders', ordersRouter)
app.use('/api/v1', apiRouter)

app.use('/api', router) monte tout le routeur sous le préfixe ; router.use(mw) applique le middleware à toutes les routes du routeur. Distinction importante :

  • app.use(mw) → middleware global, s'exécute pour TOUTES les requêtes.
  • app.use('/api', mw) → s'exécute pour toutes les requêtes commençant par /api.
  • router.use(mw) → s'exécute pour toutes les routes définies sur ce router.
  • app.METHOD('/path', mw, handler) → s'exécute uniquement pour cette route.

Pattern 5 — Async controllers, sync middlewares

Convention senior : les handlers de route sont async, les middlewares transverses (auth, logger, cors) restent synchrones quand c'est possible. Pourquoi : un middleware async qui oublie next() ou qui throw avant Express 5 bloque silencieusement la chaîne. Avec Express 5 ce n'est plus dramatique, mais la convention reste lisible.

Pattern 6 — Streaming et pipes

Express supporte le streaming via res.write / res.end et les Node streams classiques. Pour piper un fichier ou un upload vers une réponse :

ts
import { createReadStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'

app.get('/download/:id', async (req, res) => {
  const file = createReadStream(`/data/${req.params.id}.bin`)
  res.set('Content-Type', 'application/octet-stream')
  await pipeline(file, res) // gère erreurs et cleanup automatiquement
})

Important : pipeline (de stream/promises) est strictement supérieur à .pipe() car il propage les erreurs au lieu de les avaler. Régle senior : ne jamais utiliser .pipe() directement en prod.

Pour du SSE (Server-Sent Events) :

ts
app.get('/events', (req, res) => {
  res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' })
  const interval = setInterval(() => res.write(`data: ${Date.now()}\n\n`), 1000)
  req.on('close', () => clearInterval(interval))
})

Important : désactiver le compression middleware pour ces routes (ou utiliser res.flushHeaders()), sinon le client ne reçoit rien avant que le buffer soit plein.

🔄 Versions — Express 4 → 5, compat Node

Historique compact

  • Express 3 (2012-2014) — préhistoire, signature des middlewares différente.
  • Express 4 (2014-2024) — la version qui a régné dix ans. Stable, écosystème mûr, mais pas d'async natif, routing sur path-to-regexp v0.
  • Express 5 (octobre 2024, stable depuis) — async natif, path-to-regexp v8, retraits d'API legacy.

Compatibilité Node

Express 5 supporte officiellement Node 18, 20, 22 et 24 (en 2026). Node 18 entre en EOL fin avril 2025 mais Express ne casse pas dessus, simplement il ne testera plus. Recommandation : Node 22 LTS minimum en prod, Node 24 pour les nouveautés (V8, fetch natif, test runner, sqlite natif).

Breaking changes Express 4 → 5

SujetExpress 4Express 5
Async errorsnext(err) manuel ou wrapperCapturé automatiquement par le routeur
req.queryparser custom intégré, type anyqs par défaut, configurable via app.set('query parser')
Path matching[email protected] (capture nommée style :name(\\d+))path-to-regexp@8 (syntaxe {} pour optionnel, plus de regex inline)
res.sendfileprésent (deprecated)supprimé, utiliser res.sendFile
res.json(status, body)supportésupprimé, utiliser res.status(status).json(body)
app.delalias de app.deletesupprimé
Cookies signedreq.cookies.foo même signéreq.signedCookies.foo séparé
req.acceptsCharsetprésentrenommé req.acceptsCharsets (déjà en v4 mais l'ancien tombe)
pluralize des helpersinconsistentaligné

Migration en pratique

Étapes pour migrer une app Express 4 vers 5 :

  1. Bump la version : npm i express@5. Lancer les tests, repérer les ruptures runtime.
  2. Recherche/remplacement des API supprimées (res.json(404, ...)res.status(404).json(...), res.sendfileres.sendFile, app.delapp.delete).
  3. Audit des routes : les patterns :param(\\d+) avec regex inline ne marchent plus. Soit on bouge la validation en middleware, soit on utilise la nouvelle syntaxe {} pour les optionnels.
  4. Retirer les wrappers async : express-async-handler, express-async-errors, ou les monkey-patches maison. C'est une dette technique gratuite à supprimer.
  5. Cookies signés : vérifier les middlewares qui lisent req.cookies et confirmer qu'ils ne mélangent pas signés et non-signés.
  6. Tests : faire tourner toute la suite, en particulier les tests d'intégration. Express 5 ne change pas la sémantique de la pipeline, donc 95 % des tests passent sans toucher.

Pourquoi Express 5 a mis dix ans à sortir

Express 5 a été en alpha/beta depuis 2014. Les mainteneurs ont passé une décennie à hésiter entre conservatisme (ne pas casser les apps) et modernisation. Le déclencheur final : la sécurité de [email protected] (ReDoS confirmé en 2024) qui a forcé une migration cassante. Bonne nouvelle : Express 5 est maintenant stable et la communauté a digéré la migration.

⚠️ Pitfalls — 10 erreurs classiques

1. Error middleware avec 3 arguments

ts
// BUG — Express ne reconnaît pas comme error middleware
app.use((err, req, res) => res.status(500).send('oops'))

// FIX — il faut le 4ᵉ argument même si on ne l'utilise pas
app.use((err, req, res, next) => res.status(500).send('oops'))

Express identifie un error middleware par le nombre d'arguments de la fonction. C'est une convention historique, pas une option. Si tu utilises une fonction fléchée, garde les 4 paramètres explicites.

2. Ordre des middlewares

ts
app.use(express.json())
app.use('/api', apiRouter)
app.use(logger) // ← ne logge que ce qui n'a pas matché /api

Un middleware enregistré après une route qui répond ne sera jamais appelé pour cette route. L'ordre compte. Règle senior : globals d'abord (cors, helmet, json, logger), routes au milieu, 404 + error en dernier.

3. Oublier next() dans un middleware

ts
app.use((req, res) => { req.user = lookupUser(req) }) // ← jamais next()

La requête se fige. Aucune timeout par défaut (sauf si tu en mets un). Ton load balancer va déclarer le worker mort au bout de quelques minutes.

4. res.json et next() dans la même branche

ts
app.get('/', async (req, res, next) => {
  res.json({ ok: true })
  next() // ← envoie la requête vers le middleware suivant, qui peut tenter de re-envoyer
})

Une fois res.json appelé, tu ne dois plus toucher à res ni passer la main. Tester avec res.headersSent.

5. Async/await sans Express 5

Si tu es encore sur Express 4 (cas legacy) :

ts
app.get('/', async (req, res) => {
  const data = await fetchData() // throw → unhandled rejection
  res.json(data)
})

Solution : express-async-handler ou migration vers Express 5.

6. Body parser sur les mauvaises routes

ts
app.use(express.json()) // ← parse même /webhooks/stripe qui veut le raw body

Stripe (et d'autres webhooks signés) ont besoin du body brut pour vérifier la signature. Solution :

ts
app.use('/webhooks/stripe', express.raw({ type: 'application/json' }))
app.use(express.json()) // les autres routes

7. CORS mal configuré

cors() sans option autorise tout, ce qui est dangereux en prod. Senior pattern : whitelist explicite + credentials seulement quand nécessaire.

ts
app.use(cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  credentials: true,
}))

8. req.ip derrière un proxy

Sans configuration, req.ip retourne l'IP du load balancer, pas du client. Configurer app.set('trust proxy', 1) (ou un CIDR plus précis).

9. Cookies signés mal lus

En Express 5, req.cookies.foo ne contient plus les cookies signés. Il faut lire req.signedCookies.foo. Si tu fais une migration v4→v5 sans corriger ça, ton auth basée cookie casse silencieusement.

10. Path-to-regexp v8 différences

ts
// Express 4 — fonctionne
app.get('/users/:id(\\d+)', handler)

// Express 5 — KO, plus de regex inline
app.get('/users/:id', validateNumericId, handler)

La régex inline pour la validation de path n'existe plus en v5. La validation passe en middleware, ce qui est plus propre de toute façon. Pour les routes optionnelles, la nouvelle syntaxe est {} :

ts
// Express 4
app.get('/files/:name?', handler)

// Express 5
app.get('/files{/:name}', handler)

11. Confondre app.set('trust proxy') et le rate-limiter

Si tu utilises express-rate-limit derrière un proxy et que tu oublies trust proxy, tout le trafic est compté avec l'IP du load balancer → un utilisateur abusif bloque toute la planète. Toujours vérifier l'interaction trust proxy + rateLimit({ keyGenerator: req => req.ip }).

12. Helmet et CSP par défaut

app.use(helmet()) active une CSP par défaut qui peut casser des inlineScripts ou des fonts hébergés en CDN. En prod, configurer la CSP explicitement plutôt que de partir d'un défaut qui change avec les versions :

ts
app.use(helmet({ contentSecurityPolicy: { directives: { ... } } }))

🧪 Testing

Test d'intégration avec supertest

ts
import request from 'supertest'
import { app } from './app'

describe('POST /users', () => {
  it('creates a user', async () => {
    const res = await request(app)
      .post('/users')
      .send({ email: '[email protected]', name: 'Alice' })
      .expect(201)
    expect(res.body.data.email).toBe('[email protected]')
  })

  it('rejects invalid email', async () => {
    const res = await request(app)
      .post('/users')
      .send({ email: 'nope', name: 'Alice' })
      .expect(400)
    expect(res.body.error.code).toBe('VALIDATION')
  })
})

supertest démarre l'app sur un port éphémère et tape dessus via HTTP réel. Aucun mock du framework, ce qui rend les tests robustes au refactoring interne.

Test unitaire de middleware

ts
import { requireAuth } from './middlewares/auth'

it('rejects missing token', () => {
  const req = { headers: {} } as any
  const res = {} as any
  const next = jest.fn()
  requireAuth(req, res, next)
  expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 401 }))
})

Tester les middlewares isolément, sans Express, est rapide et précis. La signature (req, res, next) est trivialement mockable.

Test avec base de données

Pour les tests d'intégration qui touchent la DB, deux écoles :

  • Test container : démarrer un Postgres/Mongo en Docker par run de tests (lent mais réaliste).
  • In-memory : SQLite ou MongoMemoryServer (rapide mais ne couvre pas les specificités du SGBD).

En 2026, avec Node 24 qui embarque SQLite nativement (node:sqlite), beaucoup de stacks utilisent SQLite en tests même quand la prod est Postgres, à condition de ne pas utiliser de features Postgres-only.

🏭 Production — observabilité, shutdown, perf

Une app Express qui "marche en local" est à mi-chemin. Ce qui sépare un dev d'un staff engineer, c'est ce qui se passe quand l'app tourne 24/7 derrière un load balancer sous trafic réel. Quatre sujets non négociables.

Graceful shutdown — le piège silencieux

Par défaut, app.listen() ne sait pas s'arrêter proprement. Quand Kubernetes envoie SIGTERM (rolling deploy, scale-down, OOM kill imminent), le process doit : (1) cesser d'accepter de nouvelles connexions, (2) laisser finir les requêtes en vol, (3) fermer les pools DB/Redis, (4) sortir. Sans ça, tu coupes des requêtes en cours → 502 côté client pendant chaque déploiement.

ts
const server = app.listen(3000)

// Express 5 + Node 18.2+ : http.Server a un timeout de drain configurable.
server.headersTimeout = 60_000
server.requestTimeout = 30_000      // tue les requêtes lentes (slowloris)
server.keepAliveTimeout = 65_000    // doit être > celui de l'ALB (typiquement 60s)

async function shutdown(signal: string) {
  logger.info({ signal }, 'shutting down')
  // 1) stop d'accepter de nouvelles connexions ; le callback se déclenche
  //    quand toutes les requêtes en vol sont terminées.
  server.close(async () => {
    await Promise.allSettled([db.end(), redis.quit()])
    process.exit(0)
  })
  // 2) garde-fou : si le drain prend trop longtemps, on force.
  setTimeout(() => process.exit(1), 15_000).unref()
}

process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))

Détail critique souvent raté : keepAliveTimeout côté Node doit être supérieur à l'idle timeout de l'ALB/ELB, sinon Node ferme une connexion keep-alive que le LB croit encore ouverte → 502 sporadiques impossibles à reproduire en local. Règle : keepAliveTimeout (Node) > idle timeout (LB), et headersTimeout > keepAliveTimeout.

Observabilité — request ID, structured logs, OpenTelemetry

Trois piliers : logs structurés, traces distribuées, métriques. Express n'apporte rien nativement ; on câble l'écosystème.

ts
import { AsyncLocalStorage } from 'node:async_hooks'
import { randomUUID } from 'node:crypto'

// AsyncLocalStorage propage le request-id à travers toute la chaîne async
// SANS le passer en paramètre partout (le "contexte ambiant" propre).
export const requestContext = new AsyncLocalStorage<{ requestId: string }>()

app.use((req, res, next) => {
  const requestId = req.header('x-request-id') ?? randomUUID()
  res.setHeader('x-request-id', requestId)
  requestContext.run({ requestId }, next) // tout ce qui suit voit ce contexte
})

// N'importe où dans le code (service, repo) sans passer req :
function log(msg: string) {
  const ctx = requestContext.getStore()
  logger.info({ requestId: ctx?.requestId }, msg)
}

AsyncLocalStorage (stable depuis Node 16, optimisé depuis Node 20) est le mécanisme idiomatique pour le contexte par-requête en 2026 — il remplace les hacks à base de req traîné partout ou de cls-hooked. Pour les traces, @opentelemetry/instrumentation-express auto-instrumente les middlewares et propage le traceparent W3C ; pour les métriques, exposer un /metrics Prometheus (prom-client) avec un histogramme de latence par route + statut.

SignalOutil 2026Ce qu'on cherche dans un incident
Logspino + AsyncLocalStorage (request-id)reconstituer une requête de bout en bout
TracesOpenTelemetry (otel-collector → Tempo/Jaeger)où sont les 800ms : DB ? Stripe ? GC ?
Métriquesprom-client → Prometheus → Grafanap50/p95/p99 par route, taux d'erreur, RPS
ErreursSentry (capture dans l'error handler)grouping + stacktrace + breadcrumb

Le branchement Sentry se fait dans l'error handler central, pas en monkey-patch global : Sentry.captureException(err) avant de renvoyer l'enveloppe 500. C'est le seul endroit où toutes les erreurs convergent.

Perf — comment un staff engineer raisonne

Express ajoute ~3-15 µs de surcharge par requête vs du http brut (parsing de route, construction de la chaîne de middlewares). À l'échelle, ce n'est presque jamais le goulot : le goulot, c'est l'I/O (DB, cache, appels externes). Avant d'optimiser Express, on profile. L'ordre de grandeur réel :

  • Un middleware mal placé (parser JSON sur des routes qui n'en ont pas besoin, validation lourde avant l'auth) coûte plus que le framework lui-même.
  • express.json() parse tout le body en mémoire : un limit trop haut = vecteur DoS (un attaquant envoie 50 Mo, ton heap explose). Mettre un limit serré ('100kb' pour des API JSON typiques) est une décision de sécurité, pas de perf.
  • La sérialisation JSON de la réponse est souvent le hot path. res.json() utilise JSON.stringify. Pour des payloads massifs et répétitifs, un sérialiseur basé sur schéma (comme celui de Fastify) gagne 2-3× — c'est l'un des rares cas où migrer vaut le coup.
  • Le clustering : un process Node = un cœur. En prod, on lance N workers (PM2, node:cluster, ou — mieux — N pods derrière le LB et on laisse l'orchestrateur scaler). Préférer horizontal scaling stateless au clustering intra-process : plus simple à raisonner, à monitorer, à rollback.

Mental model perf : latence = file_d'attente_event_loop + I/O + sérialisation. Express touche surtout le premier terme (et marginalement). Si ta p99 explose, c'est presque toujours l'event loop bloqué (un JSON.parse géant, un bcrypt synchrone, une regex catastrophique) ou l'I/O lente — pas "Express est lent".

Sécurité — la checklist non négociable

helmet (headers), express-rate-limit (avec trust proxy correct), cors en whitelist, limit serré sur les parsers, validation stricte de toutes les entrées (body, query, params, headers), pas de secret en log, et un error handler qui ne leak jamais la stacktrace en prod (le client reçoit { error: { message: 'internal' } }, le détail va dans Sentry). Le requestTimeout du serveur ferme la porte aux attaques slowloris. C'est l'OWASP Node.js cheatsheet appliqué.

🎬 Cas d'usage concrets

Scénario 1 — SaaS RH legacy "PaySimple" migré Express 4 → Express 5

PaySimple est un SaaS RH français lancé en 2017 qui gère paie, congés et notes de frais pour 800 PME. Le backend tourne sur Express 4 avec ~ 220 routes, 12 microservices Express derrière un API gateway Kong, et un patchwork de wrappers maison pour gérer les erreurs async. L'équipe (6 devs backend) hérite de cinq ans de dette : express-async-handler partout, des middlewares qui throwent sans next(err), et un error middleware central avec 3 arguments qui n'attrape rien (le bug est resté caché parce que Sentry catchait quand même les unhandled rejections).

En janvier 2026, l'équipe décide de migrer vers Express 5 pour profiter de l'async natif et retirer la dette. La migration prend trois sprints : (1) bump express@5 + suite de tests qui découvre 18 routes utilisant :param(\d+) regex inline — réécrites en middleware de validation Zod ; (2) audit des cookies signés (passage de req.cookies à req.signedCookies sur 4 endroits) ; (3) suppression de express-async-handler (-12k lignes dans le diff, gros nettoyage), et fix du error middleware à 4 arguments. Bénéfice mesuré : -8 % de latence p99 (le wrapper async coûtait son prix), -3000 lignes de code, et trois bugs latents découverts pendant la migration (mutations async non awaitées qui étaient masquées par le wrapper).

Scénario 2 — API e-commerce "ModeCircuit" sous Express avec webhook Stripe

ModeCircuit est une marketplace de mode seconde-main qui fait 30k req/min en peak (soldes, drops). Le backend Express sert le checkout, les paniers, la wishlist, et gère les webhooks Stripe (paiement) et Shippo (livraison). L'équipe a un piège classique : app.use(express.json()) global qui casse la vérification de signature Stripe parce que Stripe a besoin du body brut.

La résolution senior :

ts
// Webhooks AVANT le json parser global
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  verifyStripeSignature,
  stripeWebhookHandler
)

app.post('/webhooks/shippo',
  express.raw({ type: 'application/json' }),
  verifyShippoSignature,
  shippoWebhookHandler
)

// Json parser global pour le reste
app.use(express.json({ limit: '1mb' }))

L'équipe a aussi mis app.set('trust proxy', 1) parce que l'app tourne derrière Cloudflare + ALB. Sans ça, req.ip retournait l'IP du load balancer et le rate-limiter (express-rate-limit) bloquait tout le monde dès qu'un bot tapait fort. Avec trust proxy, le rate-limiter voit la vraie IP du client. Et bonus : ils ont migré vers Express 5 en parallèle, ce qui a permis de retirer 4 middlewares legacy (express-async-errors, custom async wrappers, monkey-patches du Router) et de gagner 12 % de RPS sur les routes critiques.

Scénario 3 — Cabinet juridique "LexFidens" handler webhook DocuSign

LexFidens est un cabinet d'avocats français (60 avocats) qui développe une app interne de signature électronique pour les actes notariés. Le backend Express 5 (équipe de 3 devs) reçoit des webhooks DocuSign quand un client signe un document, met à jour le dossier dans la base, et déclenche une notification au juriste. Contraintes : (1) la signature DocuSign doit être vérifiée avec HMAC SHA256 sur le body brut ; (2) l'idempotency est critique (DocuSign retry jusqu'à 24h en cas de 5xx) ; (3) les logs doivent contenir le numéro de dossier et l'avocat assigné pour traçabilité RGPD.

L'équipe utilise Express 5 pour profiter de l'async natif (les calls vers la base notariale Postgres sont tous awaitables sans wrapper), un middleware verifyDocusignHmac qui lit req.rawBody (capturé via express.raw), et un middleware idempotencyKey qui stocke en Redis (TTL 48h) les event_id DocuSign déjà traités. Le error handler central renvoie 200 pour les events déjà vus (DocuSign arrête de retry), mais 500 pour les vraies erreurs DB. La couche logger (pino) injecte dossier_id, avocat_id et client_id dans chaque ligne pour permettre les audits CNIL. Latence moyenne : 80 ms pour traiter un webhook + écrire en base + enqueuer la notification.

🛠️ Exemple end-to-end

Cas d'usage : "endpoint /api/orders dans ModeCircuit qui crée une commande, valide la carte, debite Stripe, écrit en base, enqueue la notification email — le tout idempotent et avec error envelope unifié".

ts
// src/routes/orders.ts
import express, { type Request, type Response, type NextFunction } from 'express'
import { z } from 'zod'
import { stripe } from '../clients/stripe'
import { db } from '../clients/db'
import { redis } from '../clients/redis'
import { enqueueEmail } from '../jobs/email'
import { HttpError, requireAuth, validate } from '../middlewares'
import { logger } from '../lib/logger'

const router = express.Router()

const CreateOrderBody = z.object({
  cartId: z.string().uuid(),
  shippingAddressId: z.string().uuid(),
  paymentMethodId: z.string().startsWith('pm_'),
  idempotencyKey: z.string().uuid(),
})

const idempotencyMiddleware = async (req: Request, res: Response, next: NextFunction) => {
  const key = req.body?.idempotencyKey
  if (!key) return next(new HttpError(400, 'idempotency key required'))
  const cached = await redis.get(`order:idem:${req.user!.id}:${key}`)
  if (cached) {
    return res.status(200).json(JSON.parse(cached))
  }
  next()
}

router.post('/orders',
  requireAuth,
  validate({ body: CreateOrderBody }),
  idempotencyMiddleware,
  async (req, res, next) => {
    const log = logger.child({ userId: req.user!.id, idem: req.body.idempotencyKey })
    const tx = await db.transaction()
    try {
      const cart = await db.carts.findOne({ id: req.body.cartId, userId: req.user!.id }, { tx })
      if (!cart || cart.items.length === 0) throw new HttpError(400, 'cart empty or not found')

      const total = cart.items.reduce((s, i) => s + i.price * i.qty, 0)

      const payment = await stripe.paymentIntents.create({
        amount: Math.round(total * 100),
        currency: 'eur',
        customer: req.user!.stripeCustomerId,
        payment_method: req.body.paymentMethodId,
        confirm: true,
        off_session: true,
      }, { idempotencyKey: req.body.idempotencyKey })

      if (payment.status !== 'succeeded') {
        throw new HttpError(402, 'payment failed', { stripeStatus: payment.status })
      }

      const order = await db.orders.insert({
        userId: req.user!.id,
        cartId: cart.id,
        shippingAddressId: req.body.shippingAddressId,
        amount: total,
        currency: 'eur',
        stripePaymentIntentId: payment.id,
        status: 'paid',
      }, { tx })

      await db.carts.update({ id: cart.id }, { status: 'converted' }, { tx })
      await tx.commit()

      await enqueueEmail({ template: 'order_confirmation', userId: req.user!.id, orderId: order.id })

      const payload = { data: { orderId: order.id, amount: total, status: 'paid' } }
      await redis.setex(`order:idem:${req.user!.id}:${req.body.idempotencyKey}`, 86400, JSON.stringify(payload))

      log.info({ orderId: order.id, amount: total }, 'order created')
      res.status(201).json(payload)
    } catch (err) {
      await tx.rollback()
      log.error({ err }, 'order creation failed')
      next(err)
    }
  }
)

export { router as ordersRouter }

Ce handler combine sept patterns seniors : auth obligatoire, validation Zod, idempotency Redis (24h), transaction DB, paiement Stripe avec idempotencyKey natif (pour qu'un retry réseau ne double pas le débit), error envelope via HttpError + error handler global, et logger contextuel (pino child). En Express 5, le try/catch final pour rollback est nécessaire (la transaction doit être annulée), mais on n'a plus besoin de wrapper async — le next(err) capture tout le reste. Le endpoint tient les 30k req/min de ModeCircuit en pic parce que la transaction est courte (< 200ms p95) et que Redis sert de cache d'idempotency sans hit base à chaque retry.


🔁 Quand utiliser / éviter

Utiliser Express en 2026

  • Stack legacy : ton app tourne en prod depuis 5 ans, l'équipe connaît Express, les tests passent, la migration coûterait des mois. Ne migre pas.
  • Équipe pragmatique : tu veux un framework "boring" sans DSL, sans magie, sans décorateurs. Express te laisse écrire du JavaScript.
  • Écosystème : besoin d'un middleware exotique (auth SAML, parser XML d'un partenaire, intégration avec un SDK qui ne supporte qu'Express). Le NPM Express est gigantesque.
  • Apps petites à moyennes où la perf n'est pas un goulot. Express tient sans problème 5-10k req/s sur une instance moderne, ce qui couvre 90 % des SaaS B2B.

Éviter Express en 2026

  • Greenfield à haute perf : Fastify est 2-3× plus rapide sur les benchmarks. Si tu commences à zéro et que tu vises >50k req/s par instance, va sur Fastify.
  • Edge / serverless : Express ne fonctionne pas (ou mal) sur Cloudflare Workers, Vercel Edge, Bun. Va sur Hono.
  • Type-safety end-to-end : si tu veux que ton frontend ait automatiquement les types du backend, regarde tRPC ou Nest.
  • App avec beaucoup de domaines/modules : NestJS apporte une structure modulaire (modules, providers, DI) qui passe mieux à l'échelle équipe que des dossiers Express libres.

Match-up rapide

CritèreExpressFastifyHonoNesttRPC
Perfmoyentrès hauttrès haut (edge)dépend de l'adapterdépend du transport
Courbetrès faiblefaibletrès faibleélevéemoyenne
Type-safety natifnonpartiel (via schemas)partieltotal (decorators)total (end-to-end)
Edgenonnonouinonoui (avec Hono adapter)
Écosystèmeénormegranden croissancegrandmoyen

Évolution probable d'Express après la v5

La v5 est arrivée après dix ans de gestation, donc la v6 ne sortira pas en 2026 ni 2027. Les directions probables : meilleure intégration des Web Standards (Request/Response en interne), drop de path-to-regexp au profit du URLPattern natif Node 24, support natif de l'observabilité OpenTelemetry sans middleware tiers. Mais rien n'est promis et Express assume sa nature de framework "boring".

Pattern de migration progressive Express → Fastify

Si tu veux migrer une app Express vers Fastify sans tout réécrire d'un coup, le pattern usuel :

  1. Mount Express comme middleware Fastify via @fastify/express. Fastify devient le serveur HTTP, mais tes routes Express continuent de fonctionner.
  2. Réécrire route par route en Fastify natif, en commençant par les endpoints critiques en perf.
  3. Une fois toutes les routes migrées, retirer @fastify/express.

Coût en perf intermédiaire : Express monté dans Fastify garde la lenteur d'Express sur ses routes, mais tu n'as pas besoin de faire un big-bang.

🏋️ Exercices

Progression du concret au cassant. Chaque exercice a un Objectif et un Indice/Solution esquissé. Fais-les dans l'ordre.

Exercice 1 — Pipeline minimal typé (implement)

Objectif : monter une API Express 5 + TypeScript strict avec un GET /health, un POST /echo validé par Zod ({ message: string ≤ 280 }), une enveloppe d'erreur unifiée et un error handler à 4 arguments. req.user doit être typé via declaration merging.

Indice/Solution : tsconfig en strict, fichier types/express.d.ts avec declare global { namespace Express { interface Request { user?: ... } } }. Le validator Zod renvoie next(new HttpError(400, ...)) sur safeParse().success === false. Vérifie avec curl que /echo avec un message de 300 chars renvoie bien un 400 à l'enveloppe { error: { code, message, details } }, pas une stacktrace.

Exercice 2 — Webhook signé + raw body (implement, piège classique)

Objectif : ajouter POST /webhooks/stripe qui vérifie une signature HMAC SHA256 sur le body brut, alors que le reste de l'app utilise express.json() global. La signature invalide → 400, valide → 200.

Indice/Solution : monter express.raw({ type: 'application/json' }) sur la route webhook avant le app.use(express.json()) global. Comparer crypto.createHmac('sha256', secret).update(req.body).digest('hex') au header de signature avec crypto.timingSafeEqual (pas === — timing attack). Test de non-régression : envoyer le même payload aux deux endpoints et vérifier que le JSON est bien parsé sur les routes normales mais brut sur le webhook.

Exercice 3 — Graceful shutdown sous charge (production-grade)

Objectif : rendre l'app drainable. Sous un autocannon -c 50 -d 20 en cours, envoyer SIGTERM au process et prouver zéro requête coupée (zéro non-2xx dû à l'arrêt) tout en fermant le pool DB.

Indice/Solution : server.close(cb) arrête les nouvelles connexions et appelle cb quand les requêtes en vol finissent. Ajoute un timeout setTimeout(...).unref() de garde-fou. Règle le keepAliveTimeout > requestTimeout et au-dessus de l'idle timeout de ton LB simulé. Critère de réussite : le compteur de non2xx d'autocannon reste à 0 pendant le shutdown.

Exercice 4 — Idempotency middleware distribué (production-grade)

Objectif : écrire un middleware d'idempotency basé sur Redis pour POST /orders qui garantit qu'un retry réseau (même Idempotency-Key) ne crée jamais deux commandes, même sous deux requêtes concurrentes lancées exactement en même temps.

Indice/Solution : le piège est la course entre "lire le cache" et "écrire le résultat". Un GET puis SETEX naïf laisse passer deux requêtes simultanées. Solution senior : un SET key "in-flight" NX EX 60 atomique comme verrou — le 2ᵉ appelant voit la clé prise et attend / renvoie 409 ou poll le résultat. Stocker la réponse finale (status + body) sous la clé pour servir les retrys après complétion. Teste avec Promise.all([req(), req()]) sur la même clé → exactement une commande en base.

Exercice 5 — Break-then-fix : la fuite d'event loop (break → fix)

Objectif : reproduire une p99 qui explose, diagnostiquer, corriger. Pars du squelette, ajoute une route POST /hash qui fait crypto.pbkdf2Sync(password, salt, 600_000, 64, 'sha512') (synchrone). Lance autocannon et observe la p99 grimper en flèche et le /health devenir lent alors qu'il ne fait rien.

Indice/Solution : pbkdf2Sync bloque l'event loop ; toutes les requêtes (même /health) attendent derrière. Diagnostic : --prof ou event-loop-lag qui monte. Fix : passer à la version async crypto.pbkdf2(...) (callback/promisify) ou déporter sur un worker_threads pool. Mesure avant/après : la p99 de /health doit redevenir plate. Leçon : "Express est lent" est presque toujours un faux diagnostic — c'est l'event loop bloqué.

Exercice 6 — Break-then-fix : l'error handler fantôme (break → fix)

Objectif : reproduire le bug où l'error middleware n'attrape rien, comprendre pourquoi, corriger.

Indice/Solution : écris l'error handler avec 3 arguments (err, req, res). Lance une route qui throw. Observe : le client reçoit le HTML d'erreur par défaut d'Express, ton handler n'est jamais appelé, et rien dans Sentry. Cause : Express identifie un error middleware par fn.length === 4. Fix : ajouter le 4ᵉ paramètre next (même inutilisé). Bonus : prouve que même en Express 5 (async natif), un error handler à 3 args reste muet — l'async-catch route bien vers le pipeline d'erreur, mais ce pipeline n'a aucun handler reconnu.

🎤 En entretien

Questions seniors fréquentes sur Express, avec la réponse en une ligne (niveau staff).

Q : Pourquoi un error middleware Express doit-il avoir exactement 4 arguments ? R : Express distingue un error handler d'un middleware normal en inspectant fn.length (l'arité de la fonction) au moment de l'enregistrement ; 4(err, req, res, next) ⇒ il ne sera appelé que via next(err). C'est de l'introspection runtime, pas une option de config.

Q : Express 5 capture les erreurs async — donc on n'a plus jamais besoin de try/catch ? R : Non. Le router catche les rejections d'une async route et les route vers next(err), mais tout ce qui doit faire du cleanup (rollback de transaction, clearInterval, fermeture de fichier) reste à ta charge dans un try/catch/finally local. L'async-catch gère l'acheminement de l'erreur, pas la libération des ressources.

Q : Quelle est la différence de fond entre Express et Fastify, au-delà des benchmarks ? R : Le modèle de typage et de validation. Fastify est schema-first (JSON Schema) : il valide ET sérialise via schéma (sérialiseur compilé, d'où le 2-3× de perf) et type les requêtes par route ; Express est "untyped middleware pipeline" où req est enrichi globalement et non vérifié par le compilateur. On choisit Express pour la simplicité et l'écosystème, Fastify pour la perf et la sûreté par route.

Q : Ton app Express a une p99 qui explose sous charge. Comment tu raisonnes ? R : Je ne soupçonne jamais Express en premier — sa surcharge est de l'ordre de la microseconde. Je regarde l'event loop lag (un appel synchrone lourd : crypto sync, JSON géant, regex catastrophique) et l'I/O (DB/cache/appel externe lent via tracing OTel). Le framework est rarement le goulot ; l'event loop bloqué ou l'I/O le sont presque toujours.

🔗 Liens


Récap final. Express 5 est la version de la maturité : async natif, path-to-regexp moderne, retrait des API obsolètes. Le mental model reste un pipeline de middlewares, ce qui est simple à enseigner et à débugger. Les patterns seniors clés : auth en middleware composable, validation Zod centralisée, error envelope unifié, error handler à 4 arguments en fin de pipeline. Express reste lent comparé à Fastify et inadapté à l'edge, mais en 2026 il garde une place légitime pour les stacks legacy, les équipes qui veulent du "boring tech", et les apps où l'écosystème NPM est plus important que les 30k req/s en plus. Si tu démarres greenfield, regarde plutôt Fastify (perf + schemas), Hono (edge), NestJS (structure), ou tRPC (type-safety). Si tu maintiens une app Express 4, migre vers Express 5 sans hésiter : la migration est mécanique et tu récupères l'async natif gratuitement.

Bibliothèque tech perso — Achref