Wird geladen...

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


Ablauf der Anfrage

  1. Der Client ruft GET /api/reservations/42 auf.
  2. Der Server lädt und serialisiert die Ressource.
  3. Der Server erzeugt aus der serialisierten Repräsentation ein ETag.
  4. Die Antwort enthält ETag: "...".
  5. Der Client speichert die Antwort zusammen mit dem ETag.
  6. Bei der nächsten Anfrage sendet der Client If-None-Match: "...".
  7. Stimmt das ETag weiterhin überein, antwortet der Server mit 304 ohne 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


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-Match zurück.
  • Stimmen die Werte überein, 304 Not Modified ohne Response-Body zurückgeben.
  • Hat sich die Ressource geändert, 200 OK mit neuem Body und neuem ETag zurückgeben.