Cargando...

Caché con ETag en ASP.NET Core: If-None-Match y 304 Not Modified

Implementa GET condicional con ETag en ASP.NET Core: genera una huella de la respuesta, procesa If-None-Match y devuelve 304 cuando el recurso no ha cambiado.

Un endpoint GET suele devolver la misma representación una y otra vez aunque el recurso subyacente no haya cambiado. Enviar la respuesta JSON completa en cada solicitud consume ancho de banda innecesariamente, especialmente con respuestas grandes, clientes móviles o pantallas que se actualizan con frecuencia.

HTTP ofrece una solución sencilla: ETag e If-None-Match. El servidor genera una huella para la representación actual y la envía en el header de respuesta ETag. En la siguiente solicitud, el cliente devuelve ese valor mediante If-None-Match. Si la representación sigue siendo la misma, el servidor responde con 304 Not Modified sin volver a enviar el body de la respuesta.


Objetivo


Flujo de la solicitud

  1. El cliente solicita GET /api/reservations/42.
  2. El servidor carga y serializa el recurso.
  3. El servidor genera un ETag a partir de la representación serializada.
  4. La respuesta incluye ETag: "...".
  5. El cliente guarda la respuesta junto con el ETag.
  6. En la siguiente solicitud, el cliente envía If-None-Match: "...".
  7. Si el ETag sigue coincidiendo, el servidor devuelve 304 sin response body.

Entidad de ejemplo


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 respuesta

Es recomendable generar el ETag a partir de la representación real que expone la API en lugar de hacerlo directamente desde la entidad de EF Core. De este modo, el validador queda asociado exactamente a los datos que recibe el cliente.


public sealed record ReservationResponse(
    int Id,
    string Code,
    string Country,
    string Status,
    DateTime CreatedAt);

Paso 1: crear un helper para ETag

Un ETag fuerte puede generarse a partir de los bytes UTF-8 exactos que se devolverán al cliente. SHA-256 proporciona una huella compacta que cambia cuando cambia la representación serializada.


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;
    }
}

¿Por qué son importantes las comillas?

Los Entity Tags normalmente se transmiten como valores entre comillas. Por lo tanto, el header generado tendrá un aspecto similar a este:


ETag: "8B87A05CDA844D65..."

El cliente debería devolver ese valor sin modificarlo:


If-None-Match: "8B87A05CDA844D65..."

Paso 2: añadir Conditional GET al controller

El endpoint carga la reserva, la mapea al modelo de respuesta y la serializa una sola vez. Los mismos bytes se utilizan tanto para calcular el ETag como para generar la respuesta 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");
    }
}

Primera solicitud

La primera solicitud todavía no contiene un header If-None-Match:


GET /api/reservations/42 HTTP/1.1
Host: api.example.com

La API devuelve la representación JSON junto con su 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"
}

Segunda solicitud con If-None-Match

El cliente devuelve al servidor el ETag recibido anteriormente:


GET /api/reservations/42 HTTP/1.1
Host: api.example.com
If-None-Match: "8B87A05CDA844D65..."

Si la reserva no ha cambiado, el servidor responde con:


HTTP/1.1 304 Not Modified
ETag: "8B87A05CDA844D65..."
Cache-Control: private, no-cache

No se envía ningún body JSON. El cliente puede seguir utilizando la representación almacenada previamente en caché.


¿Qué ocurre cuando cambia el recurso?

Supongamos que el estado de la reserva cambia de active a completed. El JSON serializado cambia y, por tanto, SHA-256 genera un ETag diferente.

El valor anterior de If-None-Match deja de coincidir. La API devuelve entonces una respuesta normal 200 OK con el body actualizado y un nuevo ETag.


HTTP/1.1 200 OK
ETag: "0F7C49B2D5A81E31..."

{
  "id": 42,
  "code": "RSV-0042",
  "country": "TR",
  "status": "completed",
  "createdAt": "2026-09-16T10:30:00Z"
}

Prueba rápida con curl

Primero, solicite el recurso y anote el ETag devuelto:


curl -i \
  "https://localhost:5001/api/reservations/42"

Después, envíe el mismo ETag mediante If-None-Match:


curl -i \
  "https://localhost:5001/api/reservations/42" \
  -H 'If-None-Match: "8B87A05CDA844D65..."'

Si la representación no ha cambiado, la segunda solicitud debería devolver 304 Not Modified sin payload JSON.


¿Qué significa Cache-Control: private, no-cache?

La directiva no-cache no significa “no almacenar nunca esta respuesta”. Significa que una respuesta almacenada debe revalidarse con el servidor antes de reutilizarse. Por eso funciona especialmente bien junto con solicitudes condicionales basadas en ETag.

private indica que la respuesta está destinada a una caché privada del cliente y no a una caché compartida. Para recursos públicos puede ser más adecuada una política de caché diferente según las necesidades de la aplicación.


Los ETags basados en hash siguen ejecutando la consulta a la base de datos

Este ejemplo evita retransmitir un response body que no ha cambiado. Sin embargo, el servidor sigue consultando la base de datos, creando el DTO y serializándolo antes de poder calcular el hash.

Para APIs pequeñas, esto suele ser perfectamente aceptable. En sistemas con mayor tráfico, puede generar el ETag a partir de una versión del recurso, por ejemplo una rowversion de la base de datos, un número de versión u otro valor que cambie siempre que cambie la representación.

Este enfoque puede hacer que la validación sea más económica, ya que no siempre será necesario serializar toda la respuesta únicamente para saber si el cliente ya dispone de la versión actual.


ETag no sirve únicamente para la caché del navegador

Los ETags son útiles para cualquier cliente HTTP capaz de conservar un validador entre solicitudes. Navegadores, aplicaciones móviles, aplicaciones de escritorio, clientes API y reverse proxies pueden utilizar solicitudes condicionales.

El cliente solo necesita guardar el ETag junto con la representación almacenada en caché y devolverlo mediante If-None-Match cuando solicite de nuevo el mismo recurso.


Mejoras habituales


TL;DR

  • Genere un ETag que represente la versión actual de la respuesta.
  • Devuélvalo mediante el header de respuesta ETag.
  • El cliente devuelve después ese valor mediante If-None-Match.
  • Si los valores coinciden, devuelva 304 Not Modified sin response body.
  • Si el recurso ha cambiado, devuelva 200 OK con un nuevo body y un nuevo ETag.