Back to blog
Backend Systemsintermediate

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.

LearnixoApril 15, 20267 min read
.NETC#RESTASP.NET CoreCachingETagHTTPPerformance
Share:𝕏

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
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:

HTTP
# 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, immutable

Adding Cache-Control in ASP.NET Core

Option 1: [ResponseCache] Attribute

C#
[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)

C#
[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:

C#
// 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();
C#
[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:

C#
// 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

C#
[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 body

The 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.

C#
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.

C#
[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));
}
HTTP
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:

C#
[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));
}
HTTP
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 time

ETag 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
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
Vary: Accept-Language, Accept-Encoding

This 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.

C#
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 write

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.