Response Caching — ETags, Cache-Control, and 304 Not Modified
Implement HTTP caching correctly in ASP.NET Core — Cache-Control directives, ETag validation, conditional requests, and the output cache middleware. Stop making clients re-download data that hasn't changed.
HTTP has a built-in caching protocol that most REST APIs completely ignore. Used correctly, it lets clients skip requests entirely when data hasn't changed, reduces server load, and saves bandwidth — all without any client-side code changes.
The Two Types of HTTP Caching
Client-side caching (Cache-Control): The server tells the client how long it can reuse the response without asking again. Zero server contact for the duration of the TTL.
Validation caching (ETag / Last-Modified): The client asks "has this changed since I last fetched it?" — the server either returns 304 Not Modified (no body) or the full updated response.
The two work together: Cache-Control prevents requests for fresh data; ETags reduce bandwidth when data has changed.
Cache-Control Headers
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
Content-Type: application/json
{"id": 42, "name": "Widget"}Key directives:
| Directive | Meaning |
|-----------|---------|
| public | Any cache (browser, CDN, proxy) may store this |
| private | Only the browser may cache — not CDNs or shared caches |
| max-age=N | Cache is fresh for N seconds from the response time |
| no-cache | Must revalidate with the server before using cached copy (not "no caching") |
| no-store | Never cache — use for sensitive data (banking, auth) |
| must-revalidate | Once stale, must revalidate before serving from cache |
| immutable | Content will never change — browser won't revalidate even on reload |
Common patterns:
# Public API responses — cache for 5 minutes at CDN and browser
Cache-Control: public, max-age=300
# User-specific data — only browser cache
Cache-Control: private, max-age=60
# Never cache (auth endpoints, payment data)
Cache-Control: no-store
# Static assets with versioned URLs (/assets/app.a3f4b2.js)
Cache-Control: public, max-age=31536000, immutableAdding Cache-Control in ASP.NET Core
Option 1: [ResponseCache] Attribute
[HttpGet("{id}")]
[ResponseCache(Duration = 300, Location = ResponseCacheLocation.Any)]
public async Task<ActionResult<ProductDto>> GetById(int id)
{
// Cache-Control: public, max-age=300
}
[HttpGet("profile")]
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Client)]
public IActionResult GetProfile()
{
// Cache-Control: private, max-age=60
}
[HttpDelete("{id}")]
[ResponseCache(NoStore = true)]
public async Task<IActionResult> Delete(int id)
{
// Cache-Control: no-store
}Option 2: Set headers directly (more control)
[HttpGet("{id}")]
public async Task<ActionResult<ProductDto>> GetById(int id, CancellationToken ct)
{
var product = await _service.GetAsync(id, ct);
if (product is null) return NotFound();
Response.Headers.CacheControl = "public, max-age=300";
return Ok(ProductDto.From(product));
}Option 3: Output Cache Middleware (.NET 7+)
The Output Cache middleware caches the full response on the server side — useful when you want server-side caching without a Redis setup:
// Program.cs
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(b => b.Cache()); // default: no caching
options.AddPolicy("ProductPolicy", b => b
.Cache()
.Expire(TimeSpan.FromMinutes(5))
.Tag("products") // tag for group invalidation
.VaryByQuery("category") // separate cache entry per ?category= value
);
});
app.UseOutputCache();[HttpGet]
[OutputCache(PolicyName = "ProductPolicy")]
public async Task<ActionResult<IReadOnlyList<ProductDto>>> GetAll(
[FromQuery] string? category, CancellationToken ct)
{
return Ok(await _service.GetAllAsync(category, ct));
}Invalidate by tag when data changes:
// After creating/updating a product:
await _outputCache.EvictByTagAsync("products", ct);ETags and Conditional Requests
An ETag is a fingerprint of the response content. The server generates it; the client stores it; on the next request, the client sends it back. If the content hasn't changed, the server returns 304 Not Modified — no body, minimal bandwidth.
Generating ETags
[HttpGet("{id}")]
public async Task<ActionResult<OrderDto>> GetById(int id, CancellationToken ct)
{
var order = await _service.GetAsync(id, ct);
if (order is null) return NotFound();
var dto = OrderDto.From(order);
// Generate ETag from a hash of the content or a version field
var etag = $"\"{GenerateETag(dto)}\""; // ETag must be quoted
// Check If-None-Match header (client's cached ETag)
if (Request.Headers.IfNoneMatch == etag)
return StatusCode(304); // Not Modified — no body
Response.Headers.ETag = etag;
Response.Headers.CacheControl = "private, max-age=0, must-revalidate";
return Ok(dto);
}
private static string GenerateETag<T>(T obj)
{
var json = JsonSerializer.Serialize(obj);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexString(hash)[..16]; // first 16 chars of hash
}The request flow
First request:
Client → GET /api/orders/42
Server → 200 OK, ETag: "a3f4b2c1d5e6f7a8", full body
Second request (same resource):
Client → GET /api/orders/42
If-None-Match: "a3f4b2c1d5e6f7a8"
Server → (order unchanged) → 304 Not Modified, no body
(order changed) → 200 OK, ETag: "b1c2d3e4f5a6b7c8", new bodyThe client pays for the network round-trip but skips downloading the body when nothing changed.
ETag for DB-backed Resources (Use RowVersion)
Generating an ETag by hashing the response is expensive for large objects. A better approach: use a database RowVersion / xmin / updated_at as the ETag source.
public class Order
{
public int Id { get; set; }
// PostgreSQL: xmin is updated automatically on every row change
[DatabaseGenerated(DatabaseGeneratedOption.Computed)]
public uint RowVersion { get; set; }
// SQL Server: rowversion / timestamp column
}
// Controller
var etag = $"\"{order.RowVersion}\"";
if (Request.Headers.IfNoneMatch == etag)
return StatusCode(304);
Response.Headers.ETag = etag;No hashing needed — the DB version is the ETag.
ETags for Optimistic Concurrency (Write Safety)
ETags aren't just for reads. For PUT/PATCH, they prevent lost updates: the client must send the ETag it received, and the server rejects the write if the resource changed since then.
[HttpPut("{id}")]
public async Task<IActionResult> Update(
int id, UpdateOrderDto dto, CancellationToken ct)
{
// Client must send If-Match header with the ETag from their last GET
var ifMatch = Request.Headers.IfMatch.FirstOrDefault();
if (string.IsNullOrEmpty(ifMatch))
return BadRequest("If-Match header is required for updates.");
var order = await _db.Orders.FindAsync(id, ct);
if (order is null) return NotFound();
var currentETag = $"\"{order.RowVersion}\"";
if (ifMatch != currentETag)
return StatusCode(412); // 412 Precondition Failed — resource changed
// Safe to update — client has the latest version
order.Status = dto.Status;
await _db.SaveChangesAsync(ct);
Response.Headers.ETag = $"\"{order.RowVersion}\"";
return Ok(OrderDto.From(order));
}PUT /api/orders/42
If-Match: "a3f4b2c1d5e6f7a8"
Content-Type: application/json
{"status": "Shipped"}
→ 200 OK (update succeeded, ETag updated)
→ 412 Precondition Failed (someone else updated it first)This is the HTTP-native way to implement optimistic concurrency — no custom version fields in the request body.
Last-Modified (Simpler Alternative to ETag)
Last-Modified works like ETag but uses a timestamp instead of a hash:
[HttpGet("{id}")]
public async Task<ActionResult<ArticleDto>> GetById(int id)
{
var article = await _service.GetAsync(id);
if (article is null) return NotFound();
var lastModified = article.UpdatedAt;
// Check If-Modified-Since
if (Request.Headers.IfModifiedSince.TryParseDate(out var since)
&& lastModified <= since)
{
return StatusCode(304);
}
Response.Headers.LastModified = lastModified.ToString("R"); // RFC 1123 format
return Ok(ArticleDto.From(article));
}Second request:
If-Modified-Since: Tue, 15 Apr 2026 10:00:00 GMT
→ 304 if unchanged since that time
→ 200 with new content if updated after that timeETag is more precise (handles sub-second changes, server-side renames). Last-Modified is simpler. Use ETag when accuracy matters; Last-Modified for simple content that changes infrequently.
Vary Header — Cache Per Request Property
When a response varies by something other than the URL (Accept-Language, Accept-Encoding, authentication), tell caches:
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
Vary: Accept-Language, Accept-EncodingThis tells CDNs and browsers to keep separate cache entries per Accept-Language and Accept-Encoding value. Without Vary, a French user might get a cached English response.
Response.Headers.Vary = "Accept-Language, Accept-Encoding";What to Cache and What Not To
| Cache | Don't Cache |
|-------|------------|
| Public product/article data | Auth tokens, passwords |
| Static reference data (countries, categories) | User-specific data (unless private) |
| Search results (with Vary by query params) | POST/PUT/DELETE responses |
| API docs | Real-time data (stock prices, live feeds) |
| Images, CSS, JS | Checkout/payment state |
Quick Reference
Cache-Control: public, max-age=300 → CDN + browser, 5 min
Cache-Control: private, max-age=60 → browser only, 1 min
Cache-Control: no-store → never cache
ETag workflow:
GET → 200 + ETag: "abc"
GET + If-None-Match: "abc" → 304 (unchanged) or 200 + new ETag
PUT + If-Match: "abc" → 412 (changed by someone else) or 200 (success)
Last-Modified:
GET → 200 + Last-Modified: Tue, 15 Apr 2026 10:00:00 GMT
GET + If-Modified-Since: Tue, 15 Apr 2026 10:00:00 GMT → 304 or 200
Output Cache (.NET 7+):
builder.Services.AddOutputCache()
[OutputCache(PolicyName = "MyPolicy")]
await _cache.EvictByTagAsync("tag", ct) // invalidate on writeEnjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.