Cache ETag en ASP.NET Core : If-None-Match et 304 Not Modified
Ajoutez les requêtes GET conditionnelles avec ETag dans ASP.NET Core : générez une empreinte, vérifiez If-None-Match et renvoyez 304 si la ressource n'a pas changé.
Un endpoint GET renvoie souvent la même représentation à plusieurs reprises alors que la ressource sous-jacente n’a pas changé. Transmettre la réponse JSON complète à chaque requête consomme inutilement de la bande passante, notamment pour les réponses volumineuses, les clients mobiles ou les écrans fréquemment actualisés.
HTTP propose une solution simple : ETag et If-None-Match.
Le serveur génère une empreinte pour la représentation actuelle et l’envoie dans le header de réponse ETag.
Lors de la requête suivante, le client renvoie cette valeur via If-None-Match.
Si la représentation est toujours identique, le serveur répond avec 304 Not Modified
sans renvoyer le body de la réponse.
Objectif
- Générer un ETag stable pour une réponse GET.
- Renvoyer l’ETag dans les headers de réponse.
- Lire
If-None-Matchlors des requêtes suivantes. - Renvoyer
304 Not Modifiedlorsque la représentation n’a pas changé. - Renvoyer la réponse JSON normale lorsque l’ETag est différent.
Déroulement de la requête
- Le client appelle
GET /api/reservations/42. - Le serveur charge et sérialise la ressource.
- Le serveur génère un ETag à partir de la représentation sérialisée.
- La réponse contient
ETag: "...". - Le client stocke la réponse avec son ETag.
- Lors de la requête suivante, le client envoie
If-None-Match: "...". - Si l’ETag correspond toujours, le serveur renvoie
304sans body.
Entity d’exemple
public sealed class Reservation
{
public int Id { get; set; }
public string Code { get; set; } = "";
public string Country { get; set; } = "";
public string Status { get; set; } = "";
public DateTime CreatedAt { get; set; }
}
DTO de réponse
Il est préférable de générer l’ETag à partir de la représentation réellement exposée par l’API plutôt que directement depuis l’entity EF Core. Ainsi, le validateur correspond exactement aux données reçues par le client.
public sealed record ReservationResponse(
int Id,
string Code,
string Country,
string Status,
DateTime CreatedAt);
Étape 1 : créer un helper ETag
Un ETag fort peut être généré à partir des octets UTF-8 exacts qui seront renvoyés au client. SHA-256 fournit une empreinte compacte qui change dès que la représentation sérialisée change.
using System.Security.Cryptography;
public static class ETagHelper
{
public static string Create(ReadOnlySpan<byte> content)
{
var hash = SHA256.HashData(content);
return $"\"{Convert.ToHexString(hash)}\"";
}
public static bool Matches(
string? ifNoneMatch,
string currentEtag)
{
if (string.IsNullOrWhiteSpace(ifNoneMatch))
return false;
foreach (var raw in ifNoneMatch.Split(
',',
StringSplitOptions.RemoveEmptyEntries |
StringSplitOptions.TrimEntries))
{
if (raw == "*")
return true;
var candidate = Normalize(raw);
var current = Normalize(currentEtag);
if (string.Equals(
candidate,
current,
StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static string Normalize(string value)
{
if (value.StartsWith(
"W/",
StringComparison.OrdinalIgnoreCase))
{
return value[2..];
}
return value;
}
}
Pourquoi les guillemets sont-ils importants ?
Les Entity Tags sont normalement transmis sous forme de valeurs entre guillemets. Le header généré ressemble donc à ceci :
ETag: "8B87A05CDA844D65..."
Le client doit renvoyer cette valeur sans la modifier :
If-None-Match: "8B87A05CDA844D65..."
Étape 2 : ajouter un Conditional GET au controller
L’endpoint charge la réservation, la mappe vers le modèle de réponse et la sérialise une seule fois. Les mêmes octets sont utilisés à la fois pour calculer l’ETag et pour produire la réponse HTTP.
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
[ApiController]
[Route("api/reservations")]
public sealed class ReservationsController : ControllerBase
{
private static readonly JsonSerializerOptions JsonOptions =
new(JsonSerializerDefaults.Web);
private readonly AppDbContext _db;
public ReservationsController(AppDbContext db)
{
_db = db;
}
[HttpGet("{id:int}")]
public async Task<IActionResult> Get(
int id,
CancellationToken cancellationToken)
{
var reservation = await _db.Reservations
.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new ReservationResponse(
x.Id,
x.Code,
x.Country,
x.Status,
x.CreatedAt))
.FirstOrDefaultAsync(cancellationToken);
if (reservation is null)
return NotFound();
var payload = JsonSerializer.SerializeToUtf8Bytes(
reservation,
JsonOptions);
var etag = ETagHelper.Create(payload);
Response.Headers["ETag"] = etag;
Response.Headers["Cache-Control"] =
"private, no-cache";
var ifNoneMatch =
Request.Headers["If-None-Match"].ToString();
if (ETagHelper.Matches(ifNoneMatch, etag))
{
return StatusCode(
StatusCodes.Status304NotModified);
}
return File(
payload,
"application/json; charset=utf-8");
}
}
Première requête
La première requête ne contient pas encore de header If-None-Match :
GET /api/reservations/42 HTTP/1.1
Host: api.example.com
L’API renvoie la représentation JSON avec son ETag :
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: private, no-cache
ETag: "8B87A05CDA844D65..."
{
"id": 42,
"code": "RSV-0042",
"country": "TR",
"status": "active",
"createdAt": "2026-09-16T10:30:00Z"
}
Deuxième requête avec If-None-Match
Le client renvoie au serveur l’ETag reçu précédemment :
GET /api/reservations/42 HTTP/1.1
Host: api.example.com
If-None-Match: "8B87A05CDA844D65..."
Si la réservation n’a pas changé, le serveur répond avec :
HTTP/1.1 304 Not Modified
ETag: "8B87A05CDA844D65..."
Cache-Control: private, no-cache
Aucun body JSON n’est renvoyé. Le client peut continuer à utiliser la représentation précédemment mise en cache.
Que se passe-t-il lorsque la ressource change ?
Supposons que le statut de la réservation passe de active à completed.
Le JSON sérialisé change, donc SHA-256 produit un nouvel ETag.
L’ancienne valeur If-None-Match ne correspond plus.
L’API renvoie donc une réponse normale 200 OK avec le body mis à jour et un nouvel ETag.
HTTP/1.1 200 OK
ETag: "0F7C49B2D5A81E31..."
{
"id": 42,
"code": "RSV-0042",
"country": "TR",
"status": "completed",
"createdAt": "2026-09-16T10:30:00Z"
}
Test rapide avec curl
Commencez par demander la ressource et notez l’ETag retourné :
curl -i \
"https://localhost:5001/api/reservations/42"
Envoyez ensuite ce même ETag avec If-None-Match :
curl -i \
"https://localhost:5001/api/reservations/42" \
-H 'If-None-Match: "8B87A05CDA844D65..."'
Si la représentation n’a pas changé, la deuxième requête doit renvoyer
304 Not Modified sans payload JSON.
Que signifie Cache-Control: private, no-cache ?
La directive no-cache ne signifie pas « ne jamais stocker cette réponse ».
Elle indique qu’une réponse stockée doit être revalidée auprès du serveur avant d’être réutilisée.
Elle fonctionne donc particulièrement bien avec les requêtes conditionnelles basées sur ETag.
private indique que la réponse est destinée à un cache privé du client
plutôt qu’à un cache partagé. Pour les ressources publiques, une autre stratégie de cache
peut être plus adaptée selon l’application.
Les ETags basés sur un hash exécutent toujours la requête en base de données
Cet exemple évite de retransmettre un Response Body inchangé. Cependant, le serveur interroge toujours la base de données, crée le DTO et le sérialise avant de pouvoir calculer le hash.
Pour les petites API, cela est souvent parfaitement acceptable.
Pour les systèmes à plus fort trafic, vous pouvez générer l’ETag à partir d’une version de la ressource,
par exemple une rowversion de la base de données, un numéro de version
ou une autre valeur qui change lorsque la représentation change.
Cette approche peut rendre la validation moins coûteuse, car il n’est pas forcément nécessaire de sérialiser toute la réponse simplement pour déterminer si le client possède déjà la version actuelle.
ETag ne sert pas uniquement au cache du navigateur
Les ETags sont utiles pour tout client HTTP capable de conserver un validateur entre plusieurs requêtes. Les navigateurs, applications mobiles, applications desktop, clients API et reverse proxies peuvent tous utiliser des requêtes conditionnelles.
Le client doit simplement conserver l’ETag avec la représentation mise en cache
et le renvoyer via If-None-Match lorsqu’il demande à nouveau la même ressource.
Améliorations courantes
- Générer les ETags à partir d’une
rowversionen base de données plutôt que de hasher toute la réponse JSON. - Utiliser des stratégies de cache différentes pour les ressources publiques et spécifiques à un utilisateur.
- Appliquer le même pattern via un Action Filter ou un Endpoint Filter afin d’éviter de répéter le code dans les controllers.
- Prendre en compte les query parameters dans la stratégie de représentation lorsque la sortie dépend de filtres ou de projections.
- Utiliser
If-Matchséparément pour mettre en place une concurrence optimiste sur les opérations PUT/PATCH/DELETE.
TL;DR
- Générez un ETag représentant la version actuelle de la réponse.
- Renvoyez-le dans le header de réponse
ETag. - Le client renvoie ensuite cette valeur via
If-None-Match. - Si les valeurs correspondent, renvoyez
304 Not Modifiedsans Response Body. - Si la ressource a changé, renvoyez
200 OKavec un nouveau body et un nouvel ETag.