ETag-Caching in ASP.NET Core: If-None-Match und 304 Not Modified
Implementieren Sie bedingte GET-Anfragen mit ETags in ASP.NET Core: Response-Fingerprint erzeugen, If-None-Match prüfen und bei unveränderten Daten 304 zurückgeben.
Ein GET-Endpunkt liefert häufig dieselbe Repräsentation immer wieder zurück, obwohl sich die zugrunde liegende Ressource nicht geändert hat. Wenn bei jeder Anfrage die vollständige JSON-Antwort übertragen wird, entsteht unnötiger Datenverkehr. Das fällt besonders bei größeren Antworten, mobilen Clients oder häufig aktualisierten Ansichten ins Gewicht.
HTTP bietet dafür eine einfache Lösung: ETag und If-None-Match.
Der Server erzeugt einen Fingerprint für die aktuelle Repräsentation und sendet ihn im ETag-Response-Header.
Bei der nächsten Anfrage sendet der Client diesen Wert über If-None-Match zurück.
Ist die Repräsentation weiterhin identisch, antwortet der Server mit 304 Not Modified,
ohne den Response-Body erneut zu übertragen.
Ziel
- Ein stabiles ETag für eine GET-Antwort erzeugen.
- Das ETag über die Response-Header zurückgeben.
If-None-Matchbei nachfolgenden Anfragen auslesen.304 Not Modifiedzurückgeben, wenn sich die Repräsentation nicht geändert hat.- Die normale JSON-Antwort zurückgeben, wenn das ETag nicht übereinstimmt.
Ablauf der Anfrage
- Der Client ruft
GET /api/reservations/42auf. - Der Server lädt und serialisiert die Ressource.
- Der Server erzeugt aus der serialisierten Repräsentation ein ETag.
- Die Antwort enthält
ETag: "...". - Der Client speichert die Antwort zusammen mit dem ETag.
- Bei der nächsten Anfrage sendet der Client
If-None-Match: "...". - Stimmt das ETag weiterhin überein, antwortet der Server mit
304ohne Response-Body.
Beispiel-Entity
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; }
}
Response-DTO
Es ist sinnvoll, das ETag aus der tatsächlichen API-Repräsentation zu erzeugen und nicht direkt aus der EF-Core-Entity. Dadurch bleibt der Validator an genau die Daten gebunden, die der Client tatsächlich erhält.
public sealed record ReservationResponse(
int Id,
string Code,
string Country,
string Status,
DateTime CreatedAt);
Schritt 1: Einen ETag-Helper erstellen
Ein starkes ETag kann aus genau den UTF-8-Bytes erzeugt werden, die an den Client zurückgegeben werden. SHA-256 liefert einen kompakten Fingerprint, der sich ändert, sobald sich die serialisierte Repräsentation ändert.
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;
}
}
Warum sind die Anführungszeichen wichtig?
Entity Tags werden normalerweise als Werte in Anführungszeichen übertragen. Der erzeugte Header sieht daher beispielsweise so aus:
ETag: "8B87A05CDA844D65..."
Der Client sollte diesen Wert unverändert zurücksenden:
If-None-Match: "8B87A05CDA844D65..."
Schritt 2: Conditional GET zum Controller hinzufügen
Der Endpunkt lädt die Reservierung, mappt sie auf das Response-Modell und serialisiert sie einmal. Dieselben Bytes werden sowohl zur Berechnung des ETags als auch für die HTTP-Antwort verwendet.
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");
}
}
Erste Anfrage
Die erste Anfrage enthält noch keinen If-None-Match-Header:
GET /api/reservations/42 HTTP/1.1
Host: api.example.com
Die API liefert die JSON-Repräsentation zusammen mit ihrem ETag zurück:
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"
}
Zweite Anfrage mit If-None-Match
Der Client sendet das zuvor empfangene ETag zurück an den Server:
GET /api/reservations/42 HTTP/1.1
Host: api.example.com
If-None-Match: "8B87A05CDA844D65..."
Wenn sich die Reservierung nicht geändert hat, antwortet der Server mit:
HTTP/1.1 304 Not Modified
ETag: "8B87A05CDA844D65..."
Cache-Control: private, no-cache
Es wird kein JSON-Body übertragen. Der Client kann weiterhin seine zuvor gespeicherte Repräsentation verwenden.
Was passiert, wenn sich die Ressource ändert?
Angenommen, der Status der Reservierung ändert sich von active auf completed.
Dadurch ändert sich das serialisierte JSON und SHA-256 erzeugt ein anderes ETag.
Der alte If-None-Match-Wert stimmt nun nicht mehr überein.
Die API liefert deshalb eine normale 200 OK-Antwort mit dem aktualisierten Body und einem neuen ETag.
HTTP/1.1 200 OK
ETag: "0F7C49B2D5A81E31..."
{
"id": 42,
"code": "RSV-0042",
"country": "TR",
"status": "completed",
"createdAt": "2026-09-16T10:30:00Z"
}
Schneller Test mit curl
Rufen Sie zunächst die Ressource ab und notieren Sie das zurückgegebene ETag:
curl -i \
"https://localhost:5001/api/reservations/42"
Senden Sie anschließend dasselbe ETag über If-None-Match:
curl -i \
"https://localhost:5001/api/reservations/42" \
-H 'If-None-Match: "8B87A05CDA844D65..."'
Wenn sich die Repräsentation nicht geändert hat, sollte die zweite Anfrage
304 Not Modified ohne JSON-Payload zurückgeben.
Was bedeutet Cache-Control: private, no-cache?
Die Direktive no-cache bedeutet nicht „diese Antwort niemals speichern“.
Sie bedeutet, dass eine gespeicherte Antwort vor der erneuten Verwendung beim Server validiert werden soll.
Damit eignet sie sich gut in Kombination mit ETag-basierten Conditional Requests.
private gibt an, dass die Antwort für einen privaten Client-Cache vorgesehen ist
und nicht für einen gemeinsam genutzten Cache. Für öffentliche Ressourcen kann je nach Anwendung
eine andere Cache-Policy sinnvoll sein.
Hash-basierte ETags führen die Datenbankabfrage weiterhin aus
Dieses Beispiel verhindert die erneute Übertragung eines unveränderten Response-Bodys. Der Server fragt die Datenbank jedoch weiterhin ab, erstellt das DTO und serialisiert es, bevor der Hash berechnet werden kann.
Für kleinere APIs ist das häufig völlig ausreichend. Bei Systemen mit höherem Traffic können Sie das ETag
stattdessen aus einer Ressourcen-Version erzeugen, beispielsweise aus einer Datenbank-rowversion,
einer Versionsnummer oder einem anderen Wert, der sich bei jeder Änderung der Repräsentation ebenfalls ändert.
Dadurch kann die Validierung günstiger werden, weil nicht zwingend die vollständige Antwort serialisiert werden muss, nur um festzustellen, ob der Client bereits die aktuelle Version besitzt.
ETag ist nicht nur für Browser-Caching gedacht
ETags sind für jeden HTTP-Client nützlich, der einen Validator zwischen mehreren Anfragen speichern kann. Browser, mobile Apps, Desktop-Anwendungen, API-Clients und Reverse Proxies können Conditional Requests verwenden.
Der Client muss lediglich das ETag zusammen mit der gecachten Repräsentation speichern
und es bei der nächsten Anfrage derselben Ressource über If-None-Match zurücksenden.
Häufige Erweiterungen
- ETags aus einer Datenbank-
rowversionerzeugen, statt die vollständige JSON-Antwort zu hashen. - Unterschiedliche Cache-Policies für öffentliche und benutzerspezifische Ressourcen verwenden.
- Dasselbe Muster über einen Action Filter oder Endpoint Filter anwenden, um wiederholten Controller-Code zu vermeiden.
- Query-Parameter in die Repräsentationsstrategie einbeziehen, wenn die Ausgabe eines Endpunkts von Filtern oder Projektionen abhängt.
If-Matchseparat verwenden, wenn optimistische Parallelitätskontrolle für PUT/PATCH/DELETE implementiert wird.
TL;DR
- Ein ETag erzeugen, das die aktuelle Repräsentation beschreibt.
- Das ETag über den
ETag-Response-Header zurückgeben. - Der Client sendet den Wert anschließend über
If-None-Matchzurück. - Stimmen die Werte überein,
304 Not Modifiedohne Response-Body zurückgeben. - Hat sich die Ressource geändert,
200 OKmit neuem Body und neuem ETag zurückgeben.