Cache-Control and ETags work together in REST APIs to reduce bandwidth and speed up responses by managing freshness and validation. [1, 2]

Cache-Control (Freshness)

Cache-Control is a header that sets rules for how long a client or proxy can store a resource without asking the server again. [3, 4]

  • max-age=seconds: Tells the client to use the cached copy until that time passes.

  • no-cache: Forces the client to validate with the server every time before using a cached copy.

  • public / private: Controls whether intermediate servers like CDNs (public) or only the user’s browser (private) can store the data. [3]

ETags (Validation)

An ETag (Entity Tag) is a unique token or fingerprint—often a hash—that represents a specific version of a resource. [2, 3, 5]

  • When a cache expires or no-cache is set, the client sends the ETag back in an If-None-Match header.

  • The server checks if the resource has changed.

  • If the resource is unchanged, the server returns an empty 304 Not Modified status, saving bandwidth. [1, 2, 6]

How They Work Together

  1. Initial Request: The client requests a resource. The server responds with data, Cache-Control: max-age=60, and an ETag: “v1.0”

  2. Fresh Period: For the next 60 seconds, the client uses its local cache without network calls.

  3. Revalidation: After 60 seconds, the client makes a new request with If-None-Match: "v1.0"

  4. Result: If the server data is the same, it replies with 304 Not Modified (no body sent). If it changed, it sends 200 OK with the new data and a new ETag. [1, 2, 3, 6, 7]

Cache-Control: no-cache

no-cache vs no-store

Q : If no-cache is set, why is server checking if resource has been modified or not ?

Cache-Control: no-cache

Contrary to what the name implies, it does not mean “do not cache”. Instead, it tells the browser or proxy that it can store the response, but it must revalidate it with the origin server before using it.

The Cache-Control: no-cache directive behaves slightly differently depending on who sends it:

In Client Request - Request Header

Because the client is required to check with the server on every single request, it need to look at the incoming request headers to see if it can save bandwidth.

  • When your browser sends no-cache to a server (usually triggered when you click Refresh or Hard Reload in your browser),
    • it tells intermediate caches and the origin server: “I don’t care if you have a saved copy of this page; bypass your cache or revalidate it because I want the absolute latest version.”

In a Server Response - Response Header

  • The server tells the browser (and any intermediate CDN/proxy caches) :
    • “You can save this file, but you are not allowed to show it to the user next time without asking me first.”

The Revalidation Process

  • When a server sends a response with Cache-Control: no-cache for the first time, the browser saves the file in its local cache.

  • The next time the user needs that resource

    • Even though the client still has old data stored in its local cache along with the ETag or Last-Modified timestamp it received originally.
    • the browser / client is forced to send a Conditional request , automatically attaching these back to the server to check if the file has changed:
      • Conditional Request is achieve by attaching conditional headers like these to a new request
  • If the file hasn’t changed: The server returns an HTTP status code 304 Not Modified. The browser then loads the copy it already has stored, saving bandwidth.

  • If the file has changed: The server returns an HTTP status code 200 OK along with the brand new version of the file.

This revalidation is typically handled using validator headers like ETag (a unique file hash) or Last-Modified. [[1]


1. The Client Sends a Validation Token

Even though no-cache forces the client to talk to the server, the client still has the old data stored in its local cache along with the ETag or Last-Modified timestamp it received originally.

When making the new request, the client automatically attaches these as conditional headers:

  • If-None-Match: “xyz123” (the ETag)

  • If-Modified-Since: [Timestamp]

2. The Server Checks to Save Bandwidth

When the request hits your .NET application, the framework (or your custom code) checks if the resource has changed:

  • If the resource HAS NOT changed: .NET skips generating or transmitting the full response body. It immediately sends back a 304 Not Modified status code. The client then safely reads the data from its local cache. This saves massive amounts of server bandwidth and speeds up the response.

  • If the resource HAS changed: The server processes the request normally, generates the new data, and returns a 200 OK with the fresh payload and a new ETag.


The .NET Implementation Context

In .NET (such as ASP.NET Core), if you use the ResponseCache attribute or manually set headers:

[ResponseCache(NoStore = false, Location = ResponseCacheLocation.None)]
// This generates: Cache-Control: no-cache

If you want to completely stop the server from checking and force a full download every time without any caching at all, you must use no-store instead of no-cache.

Header ValueClient BehaviorServer Behavior
no-cacheMust ask the server before using cached data. Sends conditional headers.Checks ETag/Timestamp. Returns 304 (empty body) or 200 (full body).
no-storeDisallows caching entirely. Never stores or sends conditional headers.Skipped. Always processes a full request and returns 200 (full body).

Would you like to see how to middleware-optimize ETag generation in ASP.NET Core, or should we look at how to properly configure no-store to bypass this check completely?

Yes, first one 

To optimize ETag generation globally in ASP.NET Core, you should use custom middleware. Manually calculating hashes inside every single controller action wastes time and creates duplicate code.

By handling it in middleware, you can intercept the response right before it leaves the server, calculate a checksum of the response body, and automatically return a 304 Not Modified if it matches the client’s If-None-Match header.

Here is a high-performance implementation for ASP.NET Core 8 or 9:

1. The ETag Middleware Implementation

This middleware wraps the response stream, computes an MD5 or SHA256 hash of the final output, and determines if the payload needs to be sent.

using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;

public class ETagMiddleware
{
    private readonly RequestDelegate _next;

    public ETagMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Only optimize GET and HEAD requests
        if (context.Request.Method != HttpMethods.Get && context.Request.Method != HttpMethods.Head)
        {
            await _next(context);
            return;
        }

        // Intercept the response stream
        var originalStream = context.Response.Body;
        using var ms = new MemoryStream();
        context.Response.Body = ms;

        await _next(context);

        // Check if the response is successful and has content
        if (context.Response.StatusCode == StatusCodes.Status200OK && ms.Length > 0)
        {
            string etag = CalculateHash(ms);

            // Set the ETag header
            context.Response.Headers.ETag = etag;

            // Check if client sent If-None-Match header
            if (context.Request.Headers.TryGetValue(“If-None-Match”, out var clientEtag) && clientEtag == etag)
            {
                context.Response.StatusCode = StatusCodes.Status304NotModified;
                context.Response.ContentLength = 0; // Clear content length
                return; // Suppress writing the body, saving bandwidth
            }
        }

        // Reset memory stream position and copy back to original stream if modified
        ms.Position = 0;
        await ms.CopyToAsync(originalStream);
    }

    private static string CalculateHash(MemoryStream stream)
    {
        stream.Position = 0;
        using var sha256 = SHA256.Create();
        byte[] hashBytes = sha256.ComputeHash(stream);
       
        // Format as a strong ETag string: “hash”
        var sb = new StringBuilder();
        sb.Append(’”’);
        foreach (var b in hashBytes)
        {
            sb.Append(b.ToString(“x2”));
        }
        sb.Append(’”’);
       
        return sb.ToString();
    }
}

2. Registering the Middleware

Add this middleware to your Program.cs pipeline. It must be placed before static files or controller routing so it can intercept the outgoing response early.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Register ETag Middleware early in the pipeline
app.UseMiddleware();

app.UseHttpsRedirection();
app.MapControllers();

app.Run();

Why this approach is optimized

  • Zero Controller Overhead: Developers don’t need to remember to add ETag logic to new API endpoints. It works system-wide.

  • Bandwidth Elimination: If the client already has the data, the body is completely dropped (ms.CopyToAsync is bypassed), reducing network load to almost zero.

  • Safe Stream Handling: It strictly targets 200 OK responses for GET/HEAD methods, ensuring that error statuses (400, 500, etc.) or data-altering actions (POST, PUT) are never accidentally cached.

Would you like to explore how to extend this to support weak ETags (which bypass byte-for-byte checking), or do you want to see how to write an integration test to verify that the 304 Not Modified status code is executing correctly?

References

[1] https://dev.to [2] https://www.youtube.com [3] https://www.speakeasy.com [4] https://docs.spring.io [5] https://www.youtube.com [6] https://www.fastly.com [7] https://medium.com

Read more about best practices in the Caching Best Practices in REST API Design guide. [3]

Learn more about the fundamentals in this overview of Understanding Cache-Control and ETag for efficient web caching. [1]

[1]

[1, 2] [1, 2]

[1, 2, 3, 4]

[1, 2, 3]

(https://www.cdnetworks.com/blog/other/what-is-cache-control/)]