22 - Performance : quand chaque milliseconde compte
Ce que tu vas apprendre
- Le problème N+1 et comment le détecter
- Les batch endpoints pour réduire le nombre de requêtes
- La compression et le streaming de réponses
- Le connection pooling pour les bases de donnees
- Le pattern 202 Accepted + polling pour les opérations longues
Prerequisites
Avoir lu l'article sur les webhooks. Avoir une API en production (ou presque) et des questions sur ses performances.
Ma première API en production avait un endpoint /orders qui mettait 4 secondes a répondre. Quatre secondes pour 50 commandes. Le front affichait un spinner pendant que le serveur faisait 50 requêtes SQL pour charger les clients, 50 pour les adresses, 50 pour les items. 151 requêtes SQL pour un seul appel HTTP. C'etait un problème N+1 classique, et je ne savais meme pas que ca portait un nom.
Le problème N+1
Tu charges une liste de N éléments, puis pour chaque élément, tu fais une requête supplementaire pour charger une relation. 1 requête pour la liste + N requêtes pour les relations = N+1.
typescript// N+1 : 1 requete + 50 requetes
const orders = await db.select().from(ordersTable); // 1 requete
for (const order of orders) {
order.customer = await db.select().from(customersTable)
.where(eq(customersTable.id, order.customerId)); // 50 requetes
}
La solution : un JOIN ou un chargement en batch.
typescript// 2 requetes au total
const orders = await db.select().from(ordersTable);
const customerIds = orders.map(o => o.customerId);
const customers = await db.select().from(customersTable)
.where(inArray(customersTable.id, customerIds)); // 1 requete
// Associer en memoire
const customerMap = new Map(customers.map(c => [c.id, c]));
orders.forEach(o => o.customer = customerMap.get(o.customerId));
De 51 requêtes a 2. La latence passe de 4 secondes a 80 millisecondes. Un ORM comme Drizzle ou Prisma avec include fait ca automatiquement, mais vérifié toujours les requêtes générées.
Détecter les N+1
Ajoute un logging des requêtes SQL en dev :
typescriptconst db = drizzle(client, {
logger: {
logQuery(query, params) {
console.log(`[SQL] ${query}`);
}
}
});
Si tu vois la meme requête répétée 50 fois dans les logs, c'est un N+1. Des outils comme pg_stat_statements en PostgreSQL montrent les requêtes les plus exécutées en production.
Batch endpoints
Parfois le N+1 est cote client. Le front fait 20 requêtes pour charger 20 profils utilisateurs. La solution : un endpoint batch.
GET /users?ids=1,2,3,4,5
typescriptapp.get("/users", async (req, res) => {
const ids = (req.query.ids as string)?.split(",").map(Number);
if (!ids || ids.length === 0) {
// Comportement normal : liste paginee
return res.json(await listUsers(req.query));
}
if (ids.length > 100) {
return res.status(400).json({ detail: "Maximum 100 IDs." });
}
const users = await db.select().from(usersTable)
.where(inArray(usersTable.id, ids));
res.json({ data: users });
});
On en a parle dans l'article sur les relations, mais ca merite d'etre répété : un batch endpoint divise le nombre de requêtes HTTP par N.
Compression
Active la compression gzip ou brotli. Une réponse JSON de 50 Ko se compresse a 5 Ko. Dix fois moins de bande passante.
typescriptimport compression from "compression";
app.use(compression({
threshold: 1024, // compresse seulement si > 1 Ko
filter: (req, res) => {
if (req.headers["x-no-compression"]) return false;
return compression.filter(req, res);
}
}));
Le client envoie Accept-Encoding: gzip, br et le serveur compresse automatiquement. En production, c'est souvent le reverse proxy (nginx, Caddy) ou le CDN qui gere la compression. Ton app n'a rien a faire.
Streaming de réponses
Pour les grosses listes, au lieu de charger tous les résultats en mémoire et de sérialiser un JSON geant, tu peux streamer :
typescriptapp.get("/exports/orders", async (req, res) => {
res.set("Content-Type", "application/json");
res.write("[");
let first = true;
const cursor = db.select().from(ordersTable).cursor();
for await (const order of cursor) {
if (!first) res.write(",");
res.write(JSON.stringify(order));
first = false;
}
res.write("]");
res.end();
});
Le client recoit les donnees au fur et a mesure. La mémoire du serveur reste constante, meme pour un million de lignes. Ca fonctionne particulièrement bien pour les endpoints d'export. Sur paltemps.fr, les exports CSV sont toujours en streaming.
Connection pooling
Chaque requête SQL commence par ouvrir une connexion a la base. Ouvrir une connexion PostgreSQL prend 20-50ms. Avec un pool, les connexions sont reutilisees :
typescriptimport { Pool } from "pg";
const pool = new Pool({
host: "localhost",
database: "myapp",
max: 20, // 20 connexions max
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000
});
Sans pool : 100 requêtes simultanees = 100 connexions ouvertes = 100 x 30ms de latence en plus = la base qui refuse les connexions au-delà de max_connections.
Avec pool : 100 requêtes simultanees = 20 connexions reutilisees = zero latence de connexion = la base respire.
202 Accepted + polling
Certaines opérations prennent du temps : générer un rapport PDF, traiter un fichier video, envoyer 10 000 emails. Ne fais pas attendre le client.
POST /reports
-> 202 Accepted
{ "id": "rpt_abc", "status": "processing", "status_url": "/reports/rpt_abc" }
Le client poll le status :
GET /reports/rpt_abc
-> 200 OK
{ "id": "rpt_abc", "status": "processing", "progress": 45 }
GET /reports/rpt_abc
-> 200 OK
{ "id": "rpt_abc", "status": "completed", "download_url": "/reports/rpt_abc/download" }
typescriptapp.post("/reports", async (req, res) => {
const report = await db.insert(reportsTable).values({
status: "processing",
params: req.body
});
// Lance le traitement en arriere-plan
queue.add("generate-report", { reportId: report.id });
res.status(202).json({
id: report.id,
status: "processing",
status_url: `/reports/${report.id}`
});
});
Le 202 dit explicitement "j'ai accepte ta requête mais elle n'est pas encore terminee". C'est beaucoup plus propre qu'un 200 avec un timeout de 30 secondes qui finit en 504.
Les headers qui aident
- Transfer-Encoding: chunked : streaming sans connaître la taille totale
- Content-Encoding: gzip : compression de la réponse
- Connection: keep-alive : réutilisé la connexion TCP entre requêtes
- X-Response-Time : indique le temps de traitement cote serveur (utile pour le debug)
Résumé
- Le N+1 est le tueur de performance numero un : détecté-le avec le logging SQL
- Les batch endpoints reduisent le nombre de requêtes HTTP
- La compression divise la bande passante par 10 pour du JSON
- Le streaming garde la mémoire constante pour les gros exports
- Le connection pooling évité le coût d'ouverture de connexion a chaque requête
- Le pattern 202 + polling decharge les opérations longues
Article précédent : Webhooks Article suivant : Sécurité