ETag Caching in ASP.NET Core: If-None-Match and 304 Not Modified
Add ETag-based conditional GET support to ASP.NET Core APIs. Generate a response fingerprint, handle If-None-Match, and return 304 when the resource has not changed.
A GET endpoint often returns the same representation many times even when the underlying resource has not changed. Sending the complete JSON response on every request wastes bandwidth and can be especially noticeable for larger responses, mobile clients, or frequently refreshed screens.
HTTP provides a simple solution: ETag and If-None-Match.
The server generates a fingerprint for the current representation and sends it in the ETag response header.
On the next request, the client sends that value back in If-None-Match.
If the representation is still the same, the server returns 304 Not Modified without sending the response body again.
Goal
- Generate a stable ETag for a GET response.
- Return the ETag in the response headers.
- Read
If-None-Matchfrom subsequent requests. - Return
304 Not Modifiedwhen the representation has not changed. - Return the normal JSON response when the ETag is different.
Request Flow
- The client requests
GET /api/reservations/42. - The server loads and serializes the resource.
- The server creates an ETag from the serialized representation.
- The response includes
ETag: "...". - The client stores the response together with the ETag.
- The next request sends
If-None-Match: "...". - If the ETag still matches, the server returns
304with no response body.
Example 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
It is useful to generate the ETag from the actual API representation rather than directly from the EF Core entity. That keeps the validator tied to what the client really receives.
public sealed record ReservationResponse(
int Id,
string Code,
string Country,
string Status,
DateTime CreatedAt);
Step 1: Create an ETag Helper
A strong ETag can be created from the exact UTF-8 bytes that will be returned to the client. SHA-256 gives us a compact fingerprint that changes whenever the serialized representation changes.
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;
}
}
Why Are the Quotes Important?
Entity tags are normally sent as quoted values. The generated header therefore looks like this:
ETag: "8B87A05CDA844D65..."
The client should send that value back unchanged:
If-None-Match: "8B87A05CDA844D65..."
Step 2: Add Conditional GET to the Controller
The endpoint loads the reservation, maps it to the response model and serializes it once. The same bytes are used both to calculate the ETag and to produce the HTTP response.
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");
}
}
First Request
The first request does not have an If-None-Match header:
GET /api/reservations/42 HTTP/1.1
Host: api.example.com
The API returns the JSON representation together with its 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"
}
Second Request with If-None-Match
The client sends the previously received ETag back to the server:
GET /api/reservations/42 HTTP/1.1
Host: api.example.com
If-None-Match: "8B87A05CDA844D65..."
If the reservation has not changed, the server returns:
HTTP/1.1 304 Not Modified
ETag: "8B87A05CDA844D65..."
Cache-Control: private, no-cache
There is no JSON body. The client can continue using its previously cached representation.
What Happens When the Resource Changes?
Suppose the reservation status changes from active to completed.
The serialized JSON changes, so SHA-256 produces a different ETag.
The old If-None-Match value no longer matches, so the API returns a normal
200 OK response with the updated body and a new ETag.
HTTP/1.1 200 OK
ETag: "0F7C49B2D5A81E31..."
{
"id": 42,
"code": "RSV-0042",
"country": "TR",
"status": "completed",
"createdAt": "2026-09-16T10:30:00Z"
}
Quick Test with curl
First, request the resource and note the returned ETag:
curl -i \
"https://localhost:5001/api/reservations/42"
Then send the same ETag using If-None-Match:
curl -i \
"https://localhost:5001/api/reservations/42" \
-H 'If-None-Match: "8B87A05CDA844D65..."'
If the representation has not changed, the second request should return
304 Not Modified without the JSON payload.
What Does Cache-Control: private, no-cache Mean?
The no-cache directive does not mean “never store this response”.
It means a stored response should be revalidated with the server before it is reused.
That makes it a useful companion to ETag-based conditional requests.
private indicates that the response is intended for a private client cache rather than a shared cache.
For public resources, your caching policy may use different directives depending on the application.
Hash-Based ETags Still Execute the Query
This example avoids retransmitting an unchanged response body, but the server still queries the database, creates the DTO and serializes it before it can calculate the hash.
For small APIs this is often perfectly acceptable. For higher-traffic systems, you can generate the ETag
from a resource version instead—for example a database rowversion, version number,
or another value that changes whenever the representation changes.
That approach can make validation cheaper because you do not necessarily need to serialize the complete response just to determine whether the client already has the current version.
ETag Is Not Only for Browser Caching
ETags are useful for any HTTP client that can preserve a validator between requests: browsers, mobile apps, desktop applications, API clients and reverse proxies can all use conditional requests.
The client only needs to remember the ETag together with the cached representation and send it back using
If-None-Match when requesting the same resource again.
Common Improvements
- Generate ETags from a database
rowversioninstead of hashing the full JSON response. - Use different cache policies for public and user-specific resources.
- Apply the same pattern through an action filter or endpoint filter to avoid repeating controller code.
- Include query parameters in your representation strategy for endpoints whose output depends on filtering or projection.
- Use
If-Matchseparately when implementing optimistic concurrency for PUT/PATCH/DELETE operations.
TL;DR
- Generate an ETag that represents the current response.
- Return it using the
ETagresponse header. - The client sends the value back using
If-None-Match. - If the values match, return
304 Not Modifiedwithout the response body. - If the resource changed, return
200 OKwith a new body and a new ETag.