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
- Generar un ETag estable para una respuesta GET.
- Devolver el ETag en los headers de respuesta.
- Leer
If-None-Matchen las solicitudes posteriores. - Devolver
304 Not Modifiedcuando la representación no haya cambiado. - Devolver la respuesta JSON normal cuando el ETag sea diferente.
Flujo de la solicitud
- El cliente solicita
GET /api/reservations/42. - El servidor carga y serializa el recurso.
- El servidor genera un ETag a partir de la representación serializada.
- La respuesta incluye
ETag: "...". - El cliente guarda la respuesta junto con el ETag.
- En la siguiente solicitud, el cliente envía
If-None-Match: "...". - Si el ETag sigue coincidiendo, el servidor devuelve
304sin 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
- Generar los ETags a partir de una
rowversionde la base de datos en lugar de aplicar hash a toda la respuesta JSON. - Utilizar distintas políticas de caché para recursos públicos y recursos específicos de un usuario.
- Aplicar el mismo patrón mediante un Action Filter o Endpoint Filter para evitar repetir código en los controllers.
- Incluir los query parameters en la estrategia de representación cuando la salida del endpoint dependa de filtros o proyecciones.
- Utilizar
If-Matchpor separado al implementar concurrencia optimista para operaciones PUT/PATCH/DELETE.
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 Modifiedsin response body. - Si el recurso ha cambiado, devuelva
200 OKcon un nuevo body y un nuevo ETag.