13.3 — Optimisations backend
🎯 Objectif : faire passer un endpoint de 800 ms à 50 ms. Profiler, lire un EXPLAIN, installer un cache au bon niveau, éliminer les N+1 — chaque étape divise par 2 ou 5.
À l'issue de cet axe, tu sauras :
- Profiler une requête lente (CPU, mémoire, DB) et identifier le bottleneck
- Lire un EXPLAIN ANALYZE PostgreSQL et créer le bon index
- Détecter et éliminer le N+1 query problem
- Choisir entre cache HTTP, cache applicatif (Redis) et cache DB
- Configurer connection pooling et back-pressure
Confirmé
La hiérarchie de la lenteur backend
Section intitulée « La hiérarchie de la lenteur backend »Quand un endpoint est lent, la cause est presque toujours dans cet ordre :
1. Requête DB lente (manque d'index, N+1, scan séquentiel)2. Trop de requêtes (chacune rapide, mais 50 d'affilée)3. Calcul CPU (sérialisation JSON, parsing, hash, image)4. Appel externe lent (API tierce, S3, file)5. Lock / contention (mutex, DB row lock, single thread Node)6. Garbage collection (rare, mais visible en P99)Toujours profiler avant d’optimiser. Sans profiling, tu vas optimiser le 4e poste alors que c’est le 1er qui plombe.
Profiling — voir où va le temps
Section intitulée « Profiling — voir où va le temps »Mesurer un endpoint en bout-en-bout
Section intitulée « Mesurer un endpoint en bout-en-bout »// Hono — logger natifimport { logger } from 'hono/logger';app.use('*', logger());
// Plus précis : timing par étapeapp.get('/dashboard', async (c) => { console.time('total'); console.time('db'); const data = await db.query(...); console.timeEnd('db');
console.time('serialize'); const json = JSON.stringify(data); console.timeEnd('serialize'); console.timeEnd('total'); return c.text(json);});Tracing distribué (production)
Section intitulée « Tracing distribué (production) »En production, OpenTelemetry est devenu le standard :
import { NodeSDK } from '@opentelemetry/sdk-node';import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
new NodeSDK({ instrumentations: [getNodeAutoInstrumentations()], // exporte vers Datadog / Honeycomb / Tempo / Jaeger}).start();Tu obtiens automatiquement :
GET /dashboard (842 ms)├─ db.query (users) (12 ms)├─ db.query (orders) (610 ms) ← gros suspect├─ http.fetch stripe (98 ms)└─ json.stringify (122 ms) ← surprise !Profiling CPU — --inspect & 0x
Section intitulée « Profiling CPU — --inspect & 0x »Pour un goulot CPU :
node --inspect-brk server.js # ouvrir Chrome DevTools, onglet Profiler# ounpx 0x server.js # flame graph côté terminalRequêtes DB — le premier suspect
Section intitulée « Requêtes DB — le premier suspect »EXPLAIN ANALYZE — ton meilleur ami
Section intitulée « EXPLAIN ANALYZE — ton meilleur ami »EXPLAIN ANALYZESELECT u.id, COUNT(o.id)FROM users uLEFT JOIN orders o ON o.user_id = u.idWHERE u.country = 'FR'GROUP BY u.id;Lecture du plan :
| Opérateur | Bon | Mauvais |
|---|---|---|
Index Scan / Index Only Scan | ✅ | |
Seq Scan | sur petite table OK | sur grande = catastrophe |
Hash Join | ✅ pour gros joins | |
Nested Loop | ✅ pour petits joins | catastrophe sur gros |
Sort | ✅ si petit | mauvais si > work_mem (spill disque) |
Bitmap Heap Scan | OK |
Indexes — quoi, où, quand
Section intitulée « Indexes — quoi, où, quand »-- Index simpleCREATE INDEX ON orders (user_id);
-- Index composite : ordre des colonnes IMPORTECREATE INDEX ON orders (user_id, status, created_at);-- Utile pour : WHERE user_id = ? AND status = ?-- Inutile pour : WHERE status = ? (la première colonne manque)
-- Index partielCREATE INDEX ON orders (user_id) WHERE status = 'pending';
-- Index couvrant (PostgreSQL)CREATE INDEX ON orders (user_id) INCLUDE (total, created_at);-- Permet un Index Only Scan : pas besoin de toucher la tableRègles :
- Index sur les colonnes de
WHERE,JOIN,ORDER BY. - Pas trop : chaque index ralentit les
INSERT/UPDATE. - Surveille les
pg_stat_user_indexespour les indexes jamais utilisés.
Le N+1 problem
Section intitulée « Le N+1 problem »L’erreur la plus coûteuse en ORM. Une requête « parente » + N requêtes « filles » :
// ❌ N+1 : 1 + 100 = 101 requêtesconst posts = await db.posts.findMany();for (const post of posts) { post.author = await db.users.findUnique({ where: { id: post.authorId } });}
// ✅ Eager loading : 1 ou 2 requêtesconst posts = await db.posts.findMany({ include: { author: true } });
// ✅ Drizzle equivalent : withconst posts = await db.query.posts.findMany({ with: { author: true } });
// ✅ Variante DataLoader (GraphQL ou batch manuel)const userLoader = new DataLoader(async (ids) => db.users.findMany({ where: { id: { in: ids } } }));Détection automatique :
| Outil | Stack |
|---|---|
| Prisma metrics + middleware | Prisma 7 |
| Bullet | Rails / Django |
| n-plus-one-detector | Node |
| OpenTelemetry traces (visuellement) | toutes |
Caching — les bons étages
Section intitulée « Caching — les bons étages »Browser → CDN/Edge → Reverse proxy → Application cache → Database cachePlus on cache tôt (gauche), moins on travaille. Mais plus c’est dur à invalider.
Cache HTTP (revoir 13.2)
Section intitulée « Cache HTTP (revoir 13.2) »Cache-Control: stale-while-revalidate est ton ami pour les API publiques.
Cache applicatif — Redis / KeyDB / Memcached
Section intitulée « Cache applicatif — Redis / KeyDB / Memcached »Idéal pour les données coûteuses à calculer mais peu changeantes :
import { Redis } from 'ioredis';const redis = new Redis(process.env.REDIS_URL!);
async function getPopularPosts() { const cached = await redis.get('popular_posts'); if (cached) return JSON.parse(cached);
const data = await db.posts.findMany({ /* requête lourde */ }); await redis.set('popular_posts', JSON.stringify(data), 'EX', 300); // TTL 5 min return data;}Patterns d’invalidation
Section intitulée « Patterns d’invalidation »| Pattern | Quand | Difficulté |
|---|---|---|
| TTL fixe | Données peu critiques (top posts, stats) | ⭐ |
| Write-through | Update écrit en DB + cache | ⭐⭐ |
| Write-behind | Update au cache, flush async vers DB | ⭐⭐⭐ |
| Cache-aside + invalidation par tag | App Next.js, complexité moyenne | ⭐⭐ |
| Stale-while-revalidate | API publiques | ⭐⭐ |
Cache local (in-process) — quand ?
Section intitulée « Cache local (in-process) — quand ? »import { LRUCache } from 'lru-cache';const cache = new LRUCache({ max: 1000, ttl: 60_000 });Bon pour :
- Données ultra-chaudes lues 1000×/s.
- Évite l’aller-retour Redis (~1 ms) pour les hot paths.
Mauvais pour :
- Multi-instances (chaque pod a son cache → divergence).
- Charge importante en mémoire.
Connection pooling
Section intitulée « Connection pooling »Sans pool : chaque requête ouvre une connexion (TCP + TLS + auth) → ~50-100 ms gaspillés. Avec pool : pré-ouvertes, réutilisées.
// node-postgresimport { Pool } from 'pg';const pool = new Pool({ max: 20, // connexions simultanées idleTimeoutMillis: 30_000, connectionTimeoutMillis: 2_000,});| Stack | Pool standard |
|---|---|
| Postgres + Node | pg.Pool, Drizzle/Prisma le gèrent en interne |
| MySQL | mysql2/promise.createPool |
| Postgres serverless | PgBouncer ou Neon / Supabase pooler ou Prisma Accelerate |
File d’attente — découpler synchrone/asynchrone
Section intitulée « File d’attente — découpler synchrone/asynchrone »Tout ce qui n’a pas besoin d’être fait dans la requête HTTP doit en sortir.
| Tâche | Faire en sync ? | Solution async |
|---|---|---|
| Renvoyer les données du dashboard | ✅ | — |
| Envoyer un email de confirmation | ❌ | BullMQ / Inngest / Trigger.dev |
| Générer une vignette PDF | ❌ | Worker + S3 |
| Mettre à jour un index Algolia | ❌ | webhook + queue |
| Logger un événement | ❌ | non-bloquant |
// BullMQimport { Queue } from 'bullmq';const emailQueue = new Queue('email', { connection: redis });
// dans le handler HTTP — retour immédiatawait emailQueue.add('confirm', { userId, orderId });return c.json({ ok: true });
// dans un worker séparé (autre process)new Worker('email', async (job) => { await sendEmail(job.data);}, { connection: redis });L’utilisateur reçoit sa réponse en 50 ms au lieu d’attendre 1,2 s d’envoi SMTP.
Sérialisation — le coût caché
Section intitulée « Sérialisation — le coût caché »JSON.stringify sur 500 KB peut prendre 80-150 ms sur le main thread (single-thread Node).
Leviers :
- Streamer la réponse au lieu de la matérialiser entière.
- Pagination : retourne 50 items, pas 5000.
undicipour les fetch (plus rapide que node-fetch).- DTO dédié : ne sérialise pas les colonnes inutiles.
// Pagination cursor (mieux que offset pour les grosses tables)const items = await db.posts.findMany({ take: 50, ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), orderBy: { id: 'desc' },});Concurrence et back-pressure
Section intitulée « Concurrence et back-pressure »Node.js est single-threaded. Un calcul CPU bloquant bloque tous les utilisateurs.
| Solution | Quand |
|---|---|
| Worker Threads | Calcul CPU lourd dans le même process |
| Cluster / PM2 | Multi-cœurs |
| Service séparé (Python, Rust, Go) | Vraiment lourd ou spécialisé (ML, image) |
| Queue externe | Découplage temporel (mieux) |
Back-pressure : si tu reçois plus de requêtes que tu peux traiter, rate-limite plutôt que de tomber.
import { rateLimit } from 'hono/rate-limiter';app.use('/api/*', rateLimit({ windowMs: 60_000, limit: 100 }));Cas d’école — d’un endpoint à 1,4 s à 80 ms
Section intitulée « Cas d’école — d’un endpoint à 1,4 s à 80 ms »Endpoint /dashboard qui retourne stats utilisateur. Profiling initial :
GET /dashboard (1420 ms)├─ db.users.findOne (8 ms)├─ db.orders.findMany (12 ms)├─ for (order in orders): (1180 ms)│ └─ db.products.find (~12 ms × 90 = 1080 ms) ← N+1├─ json.stringify (180 ms) ← gros payload└─ ... (40 ms)Plan d’action :
- Eager loading des produits → 1 requête au lieu de 90 → -1 060 ms.
- Cache Redis (TTL 60 s) pour les utilisateurs avec activité < 1/min → -200 ms sur les hits.
- Pagination des derniers ordres (top 20) → JSON 4× plus petit → -130 ms sérialisation.
- Index
orders(user_id, created_at DESC)→ la requête tombe à 4 ms.
Résultat : 80 ms p50, 200 ms p95. Pas de magie, juste les bons leviers dans le bon ordre.
Auto-évaluation
Section intitulée « Auto-évaluation »Pour aller plus loin
Section intitulée « Pour aller plus loin »- Use The Index, Luke — use-the-index-luke.com — bible des indexes SQL
- PostgreSQL EXPLAIN — explain.dalibo.com — visualise les plans
- High Performance Browser Networking — Ilya Grigorik (côté réseau)
- Designing Data-Intensive Applications — Martin Kleppmann
- OpenTelemetry — opentelemetry.io
Suite : 13.4 — Accessibilité avancée pour livrer des apps utilisables par tout le monde.