REST API design - 22 - Performance : quand chaque milliseconde compte

N+1, batch endpoints, compression, streaming, connection pooling et 202 Accepted avec polling pour des APIs rapides.

REST API design 23 / 25
  1. 01 REST API design - 00 - Pourquoi le design de ton API change tout
  2. 02 REST API design - 01 - Les principes REST que personne ne lit
  3. 03 REST API design - 02 - Des URLs qui ont du sens
  4. 04 REST API design - 03 - Les méthodes HTTP, pour de vrai
  5. 05 REST API design - 04 - Les codes de statut HTTP qu'il faut connaître
  6. 06 REST API design - 05 - Body, headers et le diable dans les détails
  7. 07 REST API design - 06 - La pagination, ou comment ne pas tuer ta base
  8. 08 REST API design - 07 - Filtrage et tri sans prise de tête
  9. 09 REST API design - 08 - La validation avec Zod, gardien de ton API
  10. 10 REST API design - 09 - Erreurs : un format que tes clients vont adorer
  11. 11 REST API design - 10 - Authentification : JWT, API keys et OAuth2
  12. 12 REST API design - 11 - Versioning : quand et comment faire évoluer ton API
  13. 13 REST API design - 12 - CORS : comprendre et debugger les erreurs cross-origin
  14. 14 REST API design - 13 - Rate limiting : protéger ton API sans frustrer tes clients
  15. 15 REST API design - 14 - Caching : les bonnes réponses sont celles qu'on n'envoie pas
  16. 16 REST API design - 15 - Upload de fichiers : multipart, signed URLs et streaming
  17. 17 REST API design - 16 - Relations entre ressources : embarquer, lier ou les deux
  18. 18 REST API design - 17 - HATEOAS : des liens dans tes réponses
  19. 19 REST API design - 18 - OpenAPI : le contrat entre ton API et le monde
  20. 20 REST API design - 19 - Documentation : une API non documentee est une API inutile
  21. 21 REST API design - 20 - Testing : tester ton API sans devenir fou
  22. 22 REST API design - 21 - Webhooks : quand c'est ton API qui appelle
  23. 23 REST API design - 22 - Performance : quand chaque milliseconde compte
  24. 24 REST API design - 23 - Sécurité : les attaques que tu vas subir (et comment t'en protéger)
  25. 25 REST API design - 24 - Glossaire : tous les termes REST API en un seul endroit

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é

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.