ETags and Conditional Requests in ASP.NET Core: When to Use Them and How

Search for a command to run...

No comments yet. Be the first to comment.
If you shipped an AI agent in .NET over the last two years, there is a good chance it runs on Semantic Kernel. That was the right call at the time. But with Microsoft Agent Framework reaching GA in Ap

The first time an AI feature I shipped went down in production, nothing in my code had changed. The model provider had a bad afternoon: a wave of 429 Too Many Requests, then a stretch of 503s, and eve

If you have tried to add OpenTelemetry.Extensions.Logging to a .NET project, you already know how this ends. The restore fails, NuGet tells you the package cannot be found, and you start second-guessi

You called an endpoint that returns an Entity Framework Core entity, and instead of clean JSON you got a wall of red: System.Text.Json.JsonException: A possible object cycle was detected. If you are s

Coding Droplets
296 posts
Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.
From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.
Most teams reach for caching the moment an API gets slow, wire up Redis or output caching, and move on. But there is an older, lighter mechanism built into HTTP itself that solves two problems at once, and ETags and conditional requests in ASP.NET Core are how you tap into it. An ETag is a small fingerprint of a resource's current state. Hand it back to the client, let the client send it on the next request, and you unlock two wins from a single header: you can skip re-sending unchanged data on reads, and you can reject stale writes before they overwrite someone else's work. In production I've seen this quietly cut response payloads for polling clients to near zero, and separately stop a class of "last write wins" data-corruption bugs that are painful to reproduce and worse to explain.
The reason ETags are underused is that ASP.NET Core has no single switch that turns them on for your resources. You compute and check them yourself, which sounds fiddly until you see how few lines it actually takes. If you want the fully wired version - a reusable filter, EF Core integration, and the edge cases handled end to end - the complete implementation with everything connected lives on Patreon, ready to run and adapt. This article walks the pattern itself: what it solves, when it fits, when it does not, and the shape of the code on both the read and write side.
Think of the ETag as a version stamp that travels with the resource. The client caches the stamp; the server validates against it. That single idea powers both validation caching for GET requests and optimistic concurrency control for updates, which is why it is worth understanding as one pattern rather than two unrelated tricks.
A normal REST API is stateless and forgetful. Every GET re-serializes the full resource and ships it over the wire, even when the client already holds an identical copy from two seconds ago. Every PUT trusts that the body it received is based on the latest state, even when the client fetched that state ten minutes and three edits ago. Both assumptions cost you.
Conditional requests fix both by letting the client attach a precondition to the request. The server evaluates the precondition against the resource's current ETag and decides whether to do the full work or short-circuit:
On reads - the client sends If-None-Match: "<etag>". If the resource is unchanged, the server returns 304 Not Modified with an empty body. No serialization, no payload, just headers.
On writes - the client sends If-Match: "<etag>". If the resource changed since the client last read it, the server returns 412 Precondition Failed and refuses the write, protecting the newer state.
The formal rules for all of this live in RFC 9110's conditional requests section, which is worth a skim because the header precedence rules are subtle and Google will not save you from getting them wrong.
ETags in ASP.NET Core work as a request-response handshake that you implement at the endpoint level. There is no built-in middleware that generates a resource ETag from your data, so the flow is explicit and under your control:
Compute an ETag that represents the resource's current state (from a version column, timestamp, or content hash).
Send it back on every response in the ETag header.
On the next request, read the client's If-None-Match (for reads) or If-Match (for writes) header.
Compare the incoming validator against the freshly computed ETag and branch to 304, 412, or the normal path.
A common point of confusion: this is not the same as the Output Caching or Response Caching middleware. Those are server-side stores that decide whether to replay a cached response, and they are a different tool for a different job. If you are choosing between the server-side options, our breakdown of Output Caching vs Response Caching in ASP.NET Core covers that decision. ETag validation caching complements them: it targets bandwidth and client revalidation, not server-side hit rates.
The pattern is powerful but not universal. Reaching for it everywhere adds header plumbing that earns nothing on endpoints that never benefit.
ETags fit well when:
You have read-heavy resources that change infrequently - product catalogs, configuration, user profiles, reference data. Polling and mobile clients revalidate constantly, and 304s turn those into tiny responses.
You need lost-update protection on PUT or PATCH without holding database locks. If-Match is optimistic concurrency expressed at the HTTP layer.
Clients are bandwidth-sensitive - mobile apps, metered connections, or high-frequency dashboards.
You want cache correctness in front of a CDN or shared proxy that respects validators.
ETags are a poor fit when:
The resource changes on nearly every request - a live metrics feed produces a new ETag every time, so you pay the compute cost and still send the full body.
The payload is already tiny - the header overhead can exceed what you save.
You have a streaming or event endpoint - conditional GETs do not map onto Server-Sent Events or WebSockets.
Your write path already uses database-level concurrency tokens and clients never see or forward a version. In that case the concurrency check belongs in the data layer, and our guide on EF Core Optimistic Concurrency vs Pessimistic Locking is the better starting point.
The trade-off that bit us early on: we added ETags to a dashboard endpoint whose data refreshed every few seconds. Every request recomputed a hash, matched nothing, and returned the full payload anyway. We had added CPU work and zero savings. ETags reward stability, not churn.
An ETag is an opaque string in quotes, and there are two flavors. The distinction matters more than most tutorials admit.
Strong ETags - "a1b2c3" - mean byte-for-byte identical. Two responses with the same strong ETag are exactly the same octets. Use these for concurrency control with If-Match, because you want an exact-match guarantee before allowing a write.
Weak ETags - W/"a1b2c3" - mean semantically equivalent but not necessarily byte-identical. Use these for caching when a whitespace or ordering difference should not force a re-download.
A frequent mistake is generating a weak ETag and then using it for If-Match writes. Some intermediaries strip or normalize weak validators, and the concurrency guarantee quietly weakens. Rule of thumb from real code: weak for read caching, strong for write protection.
On the read path, compute the ETag, compare it to If-None-Match, and return 304 when they match. The typed-header helpers keep parsing correct so you are not hand-splitting quoted strings.
// GET /products/{id} - controller action (.NET 10 / ASP.NET Core 10)
var product = await _repository.GetAsync(id, ct);
if (product is null) return NotFound();
var etag = new EntityTagHeaderValue($"\"{product.Version}\"");
var incoming = Request.GetTypedHeaders().IfNoneMatch;
if (incoming.Any(tag => tag.Compare(etag, useStrongComparison: false)))
return StatusCode(StatusCodes.Status304NotModified);
Response.GetTypedHeaders().ETag = etag;
return Ok(product);
The Compare overload with useStrongComparison: false performs the weak comparison the HTTP spec mandates for If-None-Match. That single line is the difference between a correct implementation and one that fails intermittently when a proxy adds a W/ prefix.
The write side is where ETags earn the most trust. Require the client to prove it is editing the version it last saw. If the resource moved on, refuse the write with 412 instead of silently clobbering newer data.
// PUT /products/{id}
var current = await _repository.GetAsync(id, ct);
if (current is null) return NotFound();
var currentTag = new EntityTagHeaderValue($"\"{current.Version}\"");
var precondition = Request.GetTypedHeaders().IfMatch;
if (precondition.Count == 0)
return StatusCode(StatusCodes.Status428PreconditionRequired);
if (!precondition.Any(tag => tag.Compare(currentTag, useStrongComparison: true)))
return StatusCode(StatusCodes.Status412PreconditionFailed);
// safe to apply the update...
Two details worth calling out. First, useStrongComparison: true here - concurrency demands an exact match. Second, returning 428 Precondition Required when the client omits If-Match entirely is an optional but valuable move: it forces callers to opt into safe updates instead of accidentally skipping the check. In production I've seen a missing If-Match sail straight through to a blind overwrite, and 428 makes that failure loud instead of silent.
The ETag is only as trustworthy as its source. The three common strategies, in rough order of preference for API resources:
A version column - an EF Core concurrency token (byte[] RowVersion mapped with IsRowVersion(), or xmin on PostgreSQL) is ideal. The database bumps it on every update, so it is cheap, reliable, and already the source of truth for concurrency. Base64-encode it into the ETag.
A last-modified timestamp - a monotonic UpdatedAt works, but watch clock resolution; two updates in the same tick can collide.
A content hash - hashing the serialized payload always works and needs no schema change, but it costs CPU on every request and does not survive a serialization format change.
Pairing the ETag with the same value EF Core uses for its concurrency check keeps the HTTP layer and the data layer in agreement, so a 412 at the edge and a DbUpdateConcurrencyException deeper down never disagree about what "current" means. This alignment is the detail most standalone tutorials skip, and it is exactly the part that makes the pattern hold up under real traffic.
Header precedence is real. If both If-None-Match and If-Modified-Since arrive, If-None-Match wins. RFC 9110 is precise here; do not invent your own ordering.
Compression changes bytes. A strong ETag computed before gzip can mismatch a proxy that stores the compressed form. Compute strong ETags from the resource state, not the wire bytes, or use weak validators for cached reads.
Don't leak internals. An ETag derived from a raw primary key plus a predictable counter can expose row IDs or update frequency. Hash or encode it.
Test the 304 path explicitly. It is easy to ship an endpoint that sets the ETag header but never returns 304 because the comparison is wrong. A .http file or curl loop that fires a second request with the returned ETag catches this in seconds.
Get these right and ETags become one of the highest-leverage, lowest-cost patterns in an API: a few lines per endpoint that pay back in bandwidth, correctness, and fewer 2 a.m. incidents about mysteriously reverted records.
What is the difference between an ETag and the Last-Modified header in ASP.NET Core? Both are validators, but ETags are more precise. Last-Modified has one-second resolution, so two changes within the same second look identical, and it only reflects time, not content. An ETag reflects the actual resource state and can detect sub-second and content-only changes. When both are present, the ETag takes precedence. Use Last-Modified as a fallback for clients that only support time-based validation.
Do I need Output Caching or Response Caching middleware to use ETags? No. ETag validation caching is independent of the server-side caching middleware and is implemented at the endpoint level. Output and Response Caching decide whether to replay a stored response on the server; ETags let the client revalidate and receive a 304. They can be combined, but ETags work perfectly well on their own without any caching middleware registered.
When should I return 412 Precondition Failed versus 428 Precondition Required? Return 412 Precondition Failed when the client sent an If-Match header but its value no longer matches the current ETag, meaning the resource changed underneath it. Return 428 Precondition Required when the client sent no precondition at all on an update you want to protect, forcing it to fetch the current ETag and retry safely. 412 means "you are stale"; 428 means "you forgot to check."
How do I generate an ETag from an EF Core entity? Use a concurrency token as the source. Add a byte[] RowVersion property mapped with IsRowVersion() (or map xmin on PostgreSQL), then Base64-encode that value into the ETag string. The database updates the token on every write, so the ETag changes exactly when the resource changes, and the same value backs both your HTTP 412 check and EF Core's own concurrency exception.
Are ETags worth it for internal microservice APIs? Sometimes. For service-to-service calls the bandwidth savings are usually smaller, so the caching benefit is weaker. The concurrency benefit still applies if multiple callers update the same resource. If your services already coordinate through database concurrency tokens or a message queue, HTTP-level ETags may add little. Judge it per endpoint by whether reads are repetitive or writes are contended, not as a blanket policy.
I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.
GitHub: codingdroplets
YouTube: Coding Droplets
Website: codingdroplets.com