DEV Community

Cover image for Mise en cache API avec ETag et Cache-Control : Optimiser les charges utiles via les requêtes conditionnelles
Antoine Laurent
Antoine Laurent

Posted on Originally published at apidog.com

Mise en cache API avec ETag et Cache-Control : Optimiser les charges utiles via les requêtes conditionnelles

Mise en cache HTTP pour les API : ETag, 304 et concurrence optimiste

Votre API renvoie probablement le même JSON des milliers de fois par jour. Un client appelle GET /v1/products/42, reçoit 18 Ko, recommence cinq minutes plus tard et reçoit exactement les mêmes données. Rien n'a changé, mais vous avez tout de même payé la bande passante, la sérialisation et la lecture de la base de données.

Essayez Apidog dès aujourd'hui

HTTP fournit déjà les mécanismes nécessaires pour éviter ce gaspillage :

  • Cache-Control indique pendant combien de temps une réponse reste fraîche ;
  • ETag fournit une empreinte permettant de détecter les changements ;
  • 304 Not Modified évite de retransmettre le corps ;
  • If-Match protège les écritures contre les mises à jour perdues.

Ces mécanismes complètent les modèles côté client. Si vous avez consulté notre guide sur la mise en cache des réponses API dans React, cet article couvre la partie serveur.

Nous allons voir les trois couches de la mise en cache HTTP, le cycle complet d'une réponse 304, les différences entre no-cache et no-store, puis un exemple Express complet. Nous terminerons par une vérification dans Apidog.

Les trois couches de la mise en cache HTTP

La mise en cache d'une API repose sur trois décisions distinctes.

1. Fraîcheur

Combien de temps le client peut-il réutiliser une réponse sans contacter l'API ?

Cache-Control: max-age=60
Enter fullscreen mode Exit fullscreen mode

Pendant 60 secondes, le client utilise sa copie locale : aucun trafic réseau n'est généré. C'est le cache hit le moins coûteux, mais aussi le plus risqué, car le client ne peut pas détecter une modification avant l'expiration du délai.

2. Validation

Une fois la réponse périmée, le client n'a pas forcément besoin de la télécharger à nouveau. Il peut demander si elle a changé en renvoyant l'empreinte reçue précédemment.

Si la ressource est inchangée, le serveur répond :

304 Not Modified
Enter fullscreen mode Exit fullscreen mode

La réponse ne contient alors aucun corps. ETag avec If-None-Match constitue la méthode la plus précise. Last-Modified avec If-Modified-Since est une solution plus ancienne, basée sur un horodatage à la seconde.

3. Invalidation

Lorsque les données changent, comment les copies périmées sont-elles supprimées ?

  • Les caches privés expirent automatiquement avec max-age.
  • Les caches partagés et les CDN nécessitent des purges explicites, des TTL courts ou des directives comme stale-while-revalidate.
  • Les CDN doivent recevoir des règles cohérentes pour éviter de servir une représentation obsolète.

La fraîcheur réduit le nombre de requêtes, la validation évite les transferts inutiles et l'invalidation maintient l'intégrité des copies. La plupart des API ont besoin de ces trois mécanismes.

Le cycle complet d'une réponse 304

Prenons le point de terminaison GET /v1/products/42.

Première requête

Le client ne possède encore aucune copie :

GET /v1/products/42 HTTP/1.1
Host: api.example.com
Enter fullscreen mode Exit fullscreen mode

Première réponse

Le serveur renvoie le corps et les métadonnées de cache :

HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
Content-Length: 18432
Enter fullscreen mode Exit fullscreen mode

Le client stocke le corps et l'ETag. Pendant les 60 secondes suivantes, il peut servir la copie locale sans contacter le serveur.

Revalidation après 60 secondes

La copie est maintenant périmée. Le client demande si elle est toujours valide :

GET /v1/products/42 HTTP/1.1
Host: api.example.com
If-None-Match: "33a64df551425fcc55e4d42a148795d9f2"
Enter fullscreen mode Exit fullscreen mode

Ressource inchangée

Le serveur compare l'ETag reçu avec l'ETag actuel. S'ils correspondent :

HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
Enter fullscreen mode Exit fullscreen mode

Il n'y a pas de corps. Au lieu de retransmettre 18 Ko, le serveur envoie seulement quelques centaines d'octets d'en-têtes. Le client marque sa copie comme fraîche pour 60 secondes supplémentaires et la sert localement.

Si le produit a changé, le serveur renvoie un 200 OK avec le nouveau corps et un nouvel ETag. Consultez notre explication de 304 Not Modified pour plus de détails : un 304 est une instruction destinée au cache, pas une erreur.

Une requête GET conditionnelle nécessite toujours un aller-retour, l'authentification et le calcul de l'ETag actuel. Elle élimine toutefois le transfert de la charge utile et sa réanalyse côté client. Pour de grandes listes interrogées par des clients mobiles, la réduction de l'égression peut atteindre 60 à 90 %.

Les directives Cache-Control essentielles

L'en-tête Cache-Control contient de nombreuses directives. Pour les API JSON, cinq sont particulièrement importantes.

no-store et no-cache

La différence entre les deux est souvent mal comprise :

  • no-store interdit complètement le stockage dans un cache ;
  • no-cache autorise le stockage, mais impose une revalidation avant chaque réutilisation.

Utilisez no-store pour les données réellement sensibles : jetons, informations bancaires ou données personnelles que vous ne devez pas persister.

Avec un ETag, no-cache permet donc de bénéficier des réponses 304 à chaque requête tout en empêchant l'utilisation silencieuse de données périmées. Appliquer no-store à toutes les réponses désactive complètement les requêtes conditionnelles et force le retransfert du corps à chaque appel.

private

private indique que seule la copie locale du client peut stocker la réponse, jamais un cache partagé ou un CDN.

Toute réponse dépendant de l'utilisateur authentifié devrait généralement utiliser cette directive. Sans elle, un proxy mal configuré pourrait servir les données d'un compte à un autre utilisateur.

max-age

max-age définit la durée de fraîcheur en secondes. Pour de nombreuses API, une valeur comprise entre 30 et 300 secondes est suffisante. L'objectif n'est pas d'empêcher les requêtes pendant une journée, mais d'absorber les pics et les boucles de sondage.

stale-while-revalidate

Cette directive permet de servir temporairement une copie périmée pendant qu'une revalidation se déroule en arrière-plan :

Cache-Control: max-age=60, stale-while-revalidate=300
Enter fullscreen mode Exit fullscreen mode

Les utilisateurs obtiennent une réponse immédiate, tandis que l'origine est mise à jour peu après. Cloudflare, Fastly et les navigateurs prennent notamment en charge ce mécanisme.

Pour un point de terminaison de lecture authentifié, un réglage raisonnable peut être :

Cache-Control: private, max-age=60, stale-while-revalidate=120
ETag: "9f8b2c41aa73e0d5"
Enter fullscreen mode Exit fullscreen mode

La RFC 9111 décrit le comportement complet de la mise en cache HTTP et remplace la RFC 7234. Lorsqu'un CDN se comporte de manière inattendue, c'est la référence à consulter.

ETags forts et faibles

Un ETag peut être fort ou faible. Le préfixe W/ indique un ETag faible.

Un ETag fort promet une égalité octet par octet :

ETag: "33a64df551425fcc"
Enter fullscreen mode Exit fullscreen mode

Deux réponses portant le même ETag fort sont identiques. Les ETags forts sont nécessaires pour les requêtes de plage d'octets et le contrôle de concurrence avec If-Match.

Un ETag faible promet seulement une équivalence sémantique :

ETag: W/"33a64df551425fcc"
Enter fullscreen mode Exit fullscreen mode

Les octets peuvent différer — ordre des champs ou horodatage, par exemple — tout en représentant la même donnée.

La compression peut modifier ce comportement. Nginx et certains frameworks transforment les ETags forts en ETags faibles lorsqu'ils compressent une réponse à la volée, car les octets compressés ne correspondent plus au corps original. Si vos contrôles de concurrence échouent derrière un proxy, vérifiez qu'un préfixe W/ n'a pas été ajouté.

Par défaut, calculez des ETags forts sur le corps non compressé. Utilisez des ETags faibles uniquement lorsque vous servez volontairement plusieurs représentations équivalentes d'une même ressource. Consultez aussi la documentation MDN sur ETag.

Générer un ETag : hachage du corps ou version

Deux stratégies sont courantes.

Hacher le corps de la réponse

Sérialisez la réponse, calculez un hash, puis placez-le entre guillemets :

ETag: "33a64df551425fcc"
Enter fullscreen mode Exit fullscreen mode

MD5 ou SHA-1 suffisent ici : l'ETag est une empreinte, pas une fonction de sécurité.

Cette méthode est précise et ne demande aucune modification du schéma. En contrepartie, le serveur doit construire et sérialiser la réponse complète, même lorsqu'il renvoie 304. Vous économisez donc la bande passante, mais pas nécessairement le CPU ni la charge de la base de données.

Utiliser une version ou updated_at

Vous pouvez dériver l'ETag d'une donnée peu coûteuse à récupérer :

ETag: "42-v17"
Enter fullscreen mode Exit fullscreen mode

Une requête conditionnelle ne nécessite alors qu'une recherche indexée. En revanche, la version doit être incrémentée pour chaque modification affectant la réponse, y compris les changements dans des tables jointes. Oublier un cas peut produire un 304 périmé, un bug particulièrement difficile à détecter.

Commencez par le hachage du corps. Passez à un ETag basé sur une version lorsque le profilage montre que la sérialisation est un coût significatif.

Concurrence optimiste avec If-Match et 412

Un ETag peut aussi protéger les écritures contre les mises à jour perdues.

Supposons que deux administrateurs chargent simultanément le produit 42 :

  1. L'administrateur A modifie le prix et enregistre.
  2. L'administrateur B corrige une faute de frappe 30 secondes plus tard.
  3. La sauvegarde de B écrase silencieusement le nouveau prix de A avec une version périmée.

Pour éviter cela, rendez la mise à jour conditionnelle à la version connue par le client :

PUT /v1/products/42 HTTP/1.1
If-Match: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
Enter fullscreen mode Exit fullscreen mode

Le serveur compare If-Match avec l'ETag actuel :

  • si les valeurs correspondent, il applique la mise à jour et renvoie un nouvel ETag ;
  • sinon, il ne modifie rien et renvoie 412 Precondition Failed.

Le client peut alors récupérer la version fraîche, réappliquer sa modification et réessayer. Consultez la documentation sur 412 Precondition Failed.

Les API strictes renvoient également 428 Precondition Required lorsqu'une requête PUT ne contient pas If-Match. Cette règle rend le contrôle obligatoire.

CDN, proxys et en-têtes HTTP

Les caches partagés appliquent les mêmes en-têtes, avec leurs propres règles :

  • private exclut une réponse du cache CDN ;
  • s-maxage=600 définit un TTL spécifique aux caches partagés, différent du max-age du navigateur ;
  • la plupart des CDN revalident auprès de l'origine avec des requêtes conditionnelles ;
  • si l'origine renvoie 304, le CDN actualise les métadonnées de sa copie sans retransférer le corps ;
  • une API qui renvoie du JSON et du CSV à partir de la même URL doit utiliser Vary: Accept ;
  • la compression peut affaiblir les ETags.

Vérifiez toujours que votre framework envoie correctement Vary. Sans cet en-tête, un cache partagé pourrait servir du CSV à un client qui attend du JSON.

Exemple Express

Express génère des ETags faibles par défaut. Une gestion manuelle permet d'utiliser des ETags forts et de gérer le chemin d'écriture avec 412 :

import crypto from "node:crypto";
import express from "express";

const app = express();
app.use(express.json());

function etagFor(payload) {
  const hash = crypto.createHash("sha1")
    .update(JSON.stringify(payload))
    .digest("hex");
  return `"${hash}"`;
}

app.get("/v1/products/:id", async (req, res) => {
  const product = await db.products.find(req.params.id);
  const etag = etagFor(product);

  res.set("Cache-Control", "private, max-age=60, stale-while-revalidate=120");
  res.set("ETag", etag);

  if (req.get("If-None-Match") === etag) {
    return res.status(304).end();   // fingerprint matches: no body
  }
  res.json(product);
});

app.put("/v1/products/:id", async (req, res) => {
  const product = await db.products.find(req.params.id);
  const currentEtag = etagFor(product);
  const ifMatch = req.get("If-Match");

  if (!ifMatch) {
    return res.status(428).json({ error: "If-Match header required" });
  }
  if (ifMatch !== currentEtag) {
    return res.status(412).json({ error: "Resource changed since you fetched it" });
  }

  const updated = await db.products.update(req.params.id, req.body);
  res.set("ETag", etagFor(updated));
  res.json(updated);
});
Enter fullscreen mode Exit fullscreen mode

La branche 304 doit toujours renvoyer les en-têtes Cache-Control et ETag. Conformément à la RFC 9111, une réponse 304 peut mettre à jour les métadonnées de la réponse stockée. Renvoyez donc les en-têtes nécessaires au maintien de la copie du client.

Vérifier la mise en cache dans Apidog

Un code correct peut se comporter différemment une fois les middlewares et proxys ajoutés. Testez donc le comportement au niveau HTTP.

Dans Apidog, la vérification manuelle prend environ une minute :

  1. Envoyez GET /v1/products/42. Dans les en-têtes de réponse, confirmez la présence de ETag et Cache-Control, puis vérifiez que l'ETag est entre guillemets.
  2. Ajoutez If-None-Match avec la valeur copiée et envoyez à nouveau la requête. Vous devez obtenir 304 avec un corps vide. Si vous recevez 200, la comparaison des empreintes ne fonctionne pas.
  3. Modifiez le produit, renvoyez la requête et confirmez le retour à 200 avec un nouvel ETag.

Pour automatiser cette vérification après chaque déploiement, créez un scénario de test :

  1. une première requête extrait l'ETag dans une variable ;
  2. une seconde le renvoie comme If-None-Match et vérifie le statut 304 ainsi que l'absence de corps ;
  3. une troisième envoie un PUT avec un If-Match volontairement périmé, par exemple "deadbeefcafe1234", et vérifie le statut 412.

Notre guide sur les assertions API détaille la syntaxe pour vérifier les codes de statut et les en-têtes.

Exécutez ensuite le scénario en CI. Une modification de middleware qui supprimerait silencieusement vos ETags fera échouer le pipeline au lieu d'augmenter votre facture de bande passante. Vous pouvez télécharger Apidog et construire le scénario sur vos propres points de terminaison.

FAQ

Quelle est la différence entre no-cache et no-store ?

no-store interdit tout stockage : chaque requête doit télécharger la réponse complète.

no-cache autorise le stockage, mais impose une revalidation avant chaque réutilisation. Associé à un ETag, il produit donc des réponses 304 et réduit les transferts.

Utilisez no-store uniquement pour les données sensibles. L'appliquer partout est l'une des erreurs Cache-Control les plus coûteuses pour une équipe API.

Les ETags fonctionnent-ils avec POST ?

Généralement non, et c'est intentionnel. Les ETags décrivent l'état d'une ressource à une URL, tandis que POST crée habituellement une nouvelle ressource. Les caches ne stockent donc pas les réponses POST dans la plupart des cas.

Les en-têtes conditionnels utiles pour les écritures sont If-Match sur PUT, PATCH et DELETE. Si vous souhaitez mettre en cache la réponse d'un POST, vérifiez d'abord si l'opération devrait plutôt être un GET.

Une réponse 304 rend-elle mon API plus rapide ?

Elle réduit la taille du transfert, mais ne supprime pas le traitement côté serveur. L'API reçoit toujours la requête, exécute l'authentification et calcule l'ETag actuel.

Les gains concernent surtout la bande passante, la batterie mobile et le temps de rendu sur les réseaux lents. Mesurez la latence et le débit avant et après avec notre guide de test de performance API.

Dois-je utiliser ETag ou Last-Modified ?

Utilisez les deux lorsque c'est possible.

ETag est plus précis : il détecte les changements inférieurs à la seconde et les différences de contenu qu'un horodatage peut manquer. Lorsque les deux en-têtes conditionnels sont présents, If-None-Match est prioritaire sur If-Modified-Since.

Last-Modified reste utile pour les clients plus anciens et pour les caches qui l'utilisent afin d'estimer la fraîcheur. Si vous ne pouvez en envoyer qu'un, choisissez ETag.

Top comments (0)