HTTP Caching: Cache-Control, ETag, 304 Not Modified and Vary to Spare the Server
The HTTP cache is driven by headers, not the server: while max-age holds the request never arrives, and afterwards an ETag request gets a bodiless 304. In our test the menu's second request came from the browser with 0 bytes, and Angular HttpClient saw the 304 as a 200. Behind nginx, a book response without Vary gave an English user the Turkish price, and a cart marked public showed Bob Alice's cart.
Version note: The code in this post was built and run with .NET 10; nginx 1.29 served as the shared cache, with Angular 22.2 and Chrome 156 on the client side.
Guess First
Bookstore's Angular front end requests a book's details from the API (Application Programming Interface) through HttpClient. The server puts an ETag on the response, the browser sends that value back in If-None-Match on the next request, and the server returns a bodiless 304 Not Modified. First question: which status code does the subscribe in the Angular code see, 304 or 200?
Second question: the cart endpoint returns Cache-Control: public, max-age=60 and there is a reverse proxy cache in front of it. Alice requests her cart with Authorization: Bearer alice; a few seconds later Bob requests the same URL (Uniform Resource Locator) with his own token. Whose cart does Bob see?
Cache-Control: Who Stores It, and for How Long
The ASP.NET Core Web API series post "Caching in ASP.NET Core Web API: IMemoryCache, Redis and Response Caching" showed [ResponseCache], the Cache-Control: public,max-age=60 header and Age. This post picks up from there and leans on the two documents that define the rules of the HTTP (Hypertext Transfer Protocol) cache: RFC (Request for Comments) 9111 for cache behaviour, and RFC 9110 for conditional requests and ETag. Vocabulary first: RFC 9111 defines two kinds of cache. A private cache belongs to a single user; the browser is the typical example. A shared cache serves many users: a reverse proxy, a CDN (Content Delivery Network), a corporate proxy.
| Response directive | Meaning | In Bookstore |
|---|---|---|
max-age=60 | The response is fresh for 60 seconds; during that time the cache uses it without asking the server | Category menu |
s-maxage=300 | Overrides max-age for shared caches only; once it runs out it can't be used without validation | Menu, 5 minutes at the proxy |
no-cache | May be stored, but must be validated with the server before every use | Cart |
no-store | No cache stores any part of the request or the response | Payment and card details |
private | Shared caches don't store it; the browser may | Anything per-user |
public | May be stored even where it otherwise couldn't, including a response to a request with Authorization | Only responses that are truly the same for everyone |
stale-while-revalidate=30 | For 30 seconds after expiry, the stale copy may be served while it is refreshed in the background (RFC 5861) | Menu |
The names mislead, and the first two get mixed up most: no-cache doesn't say "don't cache", it says "ask every time"; the real "don't store" is no-store. Then there's what sending no header at all means. Per section 4.2.2 of RFC 9111, when there is no explicit lifetime a cache may assign a heuristic freshness lifetime; with a Last-Modified present, the suggested typical fraction is 10% of the time since the last change. So a response without Cache-Control is not the "uncached" response you might assume.
ETag and 304: An Answer Without a Body
When the freshness lifetime runs out, a cache doesn't throw the response away; it asks the server "is what I have still valid?". That question is a conditional request: it sends the ETag it holds in If-None-Match, and the Last-Modified in If-Modified-Since. If nothing changed, the server returns a bodiless 304. ASP.NET Core doesn't do this for you; we wrote a small helper that makes the decision:
public static class Conditional
{
// RFC 9110: If-Modified-Since is ignored when If-None-Match is present; ETags use weak comparison
public static bool IsNotModified(HttpRequest request, EntityTagHeaderValue etag, DateTimeOffset? lastModified)
{
RequestHeaders headers = request.GetTypedHeaders();
if (headers.IfNoneMatch.Count > 0)
return headers.IfNoneMatch.Any(t => t.Equals(EntityTagHeaderValue.Any) || t.Compare(etag, useStrongComparison: false));
if (lastModified is { } modified && headers.IfModifiedSince is { } since)
return TruncateToSeconds(modified) <= since;
return false;
}
private static DateTimeOffset TruncateToSeconds(DateTimeOffset value) =>
new(value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second, value.Offset);
}
Two rules come straight from RFC 9110: if If-None-Match is present, If-Modified-Since is ignored, and If-None-Match is compared with the weak comparison function. TruncateToSeconds exists because the HTTP date format carries no fractions of a second: without truncation, an UpdatedAt with milliseconds never equals the date in the header and a 304 never comes back.
For the menu we derive the ETag from the content itself: we serialize the body and take the first 16 hex characters of a hash from the SHA (Secure Hash Algorithm) family, SHA-256. For responses that are assembled from several sources and have no version in the database, this is the most practical route:
app.MapGet("/api/categories/menu", (HttpContext http, CatalogStore store) =>
{
byte[] body = JsonSerializer.SerializeToUtf8Bytes(store.Menu, json);
EntityTagHeaderValue etag = new($"\"{Convert.ToHexString(SHA256.HashData(body))[..16]}\"");
http.Response.Headers.CacheControl = "public, max-age=60, s-maxage=300, stale-while-revalidate=30";
http.Response.Headers.ETag = etag.ToString();
if (Conditional.IsNotModified(http.Request, etag, lastModified: null))
return Results.StatusCode(StatusCodes.Status304NotModified);
return Results.Bytes(body, "application/json");
});
$ curl -i localhost:17525/api/categories/menu
HTTP/1.1 200 OK
Content-Length: 79
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30
ETag: "436F5A205A91F9FB"
[{"id":1,"name":"Fiction"},{"id":2,"name":"Science"},{"id":3,"name":"History"}]
$ curl -i localhost:17525/api/categories/menu -H 'If-None-Match: "436F5A205A91F9FB"'
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30
ETag: "436F5A205A91F9FB"
We write the headers before returning because of the rule RFC 9110 sets for 304: the server must send the ETag, Vary, Cache-Control and Expires headers it would have sent in a 200; the cache uses them to update the freshness information of its stored copy. A hash-based ETag has a cost: even when you return 304, you still have to build and hash the body. The gain is bandwidth and the client's parsing time, not database load. If you want to spare the database too, you need version information.
Version-Based ETag and Last-Modified
The book record has a Version counter and an UpdatedAt; in SQL (Structured Query Language) Server a rowversion column does the same job. The price is formatted according to the request's language, so we fold the culture into the ETag too:
app.MapGet("/api/books/{id:int}", (int id, HttpContext http, CatalogStore store) =>
{
if (!store.Books.TryGetValue(id, out Book? book)) return Results.NotFound();
CultureInfo culture = PickCulture(http.Request);
// each language representation of the same URL carries its own ETag
EntityTagHeaderValue etag = new($"\"{book.Version}-{culture.Name}\"");
http.Response.Headers.CacheControl = "public, max-age=30";
http.Response.Headers.ETag = etag.ToString();
http.Response.GetTypedHeaders().LastModified = book.UpdatedAt;
http.Response.Headers.Vary = "Accept-Language";
if (Conditional.IsNotModified(http.Request, etag, book.UpdatedAt))
return Results.StatusCode(StatusCodes.Status304NotModified);
return Results.Ok(new { book.Id, book.Title, priceText = book.Price.ToString("N2", culture), book.Version });
});
$ curl -i localhost:17525/api/books/42 -H 'Accept-Language: tr-TR'
HTTP/1.1 200 OK
Cache-Control: public, max-age=30
ETag: "1-tr-TR"
Last-Modified: Sun, 01 Nov 2026 09:30:00 GMT
Vary: Accept-Language
{"id":42,"title":"Distributed Systems Basics","priceText":"1.249,50","version":1}
$ curl -i localhost:17525/api/books/42 -H 'Accept-Language: tr-TR' -H 'If-Modified-Since: Sun, 01 Nov 2026 09:30:00 GMT'
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=30
ETag: "1-tr-TR"
Last-Modified: Sun, 01 Nov 2026 09:30:00 GMT
Vary: Accept-Language
$ curl -s -X PUT localhost:17525/api/books/42/price -H 'Content-Type: application/json' -d '{"price":1199.90}' -w '%{http_code}'
204
$ curl -i localhost:17525/api/books/42 -H 'Accept-Language: tr-TR' -H 'If-None-Match: "1-tr-TR"'
HTTP/1.1 200 OK
Cache-Control: public, max-age=30
ETag: "2-tr-TR"
Last-Modified: Sun, 08 Nov 2026 02:24:04 GMT
Vary: Accept-Language
{"id":42,"title":"Distributed Systems Basics","priceText":"1.199,90","version":2}
$ curl -s localhost:17525/stats
{"/api/books/42 200":2,"/api/books/42 304":2,"/api/books/42/price 204":1,"/api/categories/menu 200":1,"/api/categories/menu 304":1}
When the price changed, the version went up to 2 and the request carrying the old ETag received the new body. Here the ETag comes from a number read from the database; in a real application you could read only the Version column to decide on 304 and never load the rest of the book. Had we left the culture out of the ETag, the Turkish and English representations would carry the same validator, and a cache could refresh one with the other's 304.
Through a Shared Cache's Eyes: Vary
To see the behaviour in a real shared cache we put nginx in front of the API; our only addition is writing the cache status into a header:
proxy_cache_path /var/cache/nginx/api keys_zone=api:10m max_size=100m;
server {
listen 80;
location / {
proxy_pass http://host.docker.internal:17525;
proxy_cache api;
add_header X-Cache-Status $upstream_cache_status always;
}
}
We requested the menu through the proxy twice: the first was a MISS, the second a HIT, and the server counter showed a single 200. Then we started the book endpoint without the Vary line and requested the same URL in two languages:
$ curl -i localhost:17526/api/books/42 -H 'Accept-Language: tr-TR'
HTTP/1.1 200 OK
Cache-Control: public, max-age=30
ETag: "1-tr-TR"
Last-Modified: Sun, 01 Nov 2026 09:30:00 GMT
X-Cache-Status: MISS
{"id":42,"title":"Distributed Systems Basics","priceText":"1.249,50","version":1}
$ curl -i localhost:17526/api/books/42 -H 'Accept-Language: en-US'
HTTP/1.1 200 OK
Cache-Control: public, max-age=30
ETag: "1-tr-TR"
Last-Modified: Sun, 01 Nov 2026 09:30:00 GMT
X-Cache-Status: HIT
{"id":42,"title":"Distributed Systems Basics","priceText":"1.249,50","version":1}
The user asking for English got the price formatted for Turkish. The proxy's key is just the URL; the header that tells it to look at the language is Vary. With the line back in place:
$ curl -i localhost:17526/api/books/42 -H 'Accept-Language: en-US'
HTTP/1.1 200 OK
Cache-Control: public, max-age=30
ETag: "1-en-US"
Last-Modified: Sun, 01 Nov 2026 09:30:00 GMT
Vary: Accept-Language
X-Cache-Status: MISS
{"id":42,"title":"Distributed Systems Basics","priceText":"1,249.50","version":1}
$ curl -i localhost:17526/api/books/42 -H 'Accept-Language: tr-TR'
HTTP/1.1 200 OK
Cache-Control: public, max-age=30
ETag: "1-tr-TR"
Last-Modified: Sun, 01 Nov 2026 09:30:00 GMT
Vary: Accept-Language
X-Cache-Status: HIT
{"id":42,"title":"Distributed Systems Basics","priceText":"1.249,50","version":1}
Section 4.1 of RFC 9111 defines this as the secondary part of the cache key: every request header named in Vary must match for a stored response to be used. Count the cost too: Accept-Language can take dozens of different values across browsers, and each one is a separate entry. Reducing the language to a few supported values on the server limits the number of distinct responses, but the proxy still separates entries by the incoming header value; normalizing those values is the job of the proxy or the CDN.
public, private and Authorization
Now the second guess. We first started the cart endpoint with public, max-age=60:
$ curl -i localhost:17526/api/me/cart -H 'Authorization: Bearer alice'
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
X-Cache-Status: MISS
{"user":"alice","items":["Book 42"]}
$ curl -i localhost:17526/api/me/cart -H 'Authorization: Bearer bob'
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
X-Cache-Status: HIT
{"user":"alice","items":["Book 42"]}
Bob saw Alice's cart. The proxy broke no rule here: section 3.5 of RFC 9111 says a shared cache may reuse a response to a request with an Authorization header for others only if the response carries public, s-maxage or must-revalidate, and 5.2.2.9 says public permits exactly that. The mistake belongs to the code that said "let's put public on everything, it'll be faster". The right way:
// demo only: treats "Bearer <user>" as identity, this is not real authentication
app.MapGet("/api/me/cart", (HttpContext http) =>
{
string? user = http.Request.Headers.Authorization.ToString() is { Length: > 7 } auth ? auth[7..] : null;
if (user is null) return Results.Unauthorized();
http.Response.Headers.CacheControl = "private, no-cache";
return Results.Ok(new { user, items = user == "alice" ? new[] { "Book 42" } : ["Book 7", "Book 9"] });
});
$ curl -i localhost:17526/api/me/cart -H 'Authorization: Bearer bob'
HTTP/1.1 200 OK
Cache-Control: private, no-cache
X-Cache-Status: MISS
{"user":"bob","items":["Book 7","Book 9"]}
In an application that carries identity in a cookie the risk is bigger, because the RFC has no protection for cookies like the one for Authorization. Always put private on per-user responses, and no-store on anything carrying money or identity data.
The Limits of [ResponseCache] and the Middleware
[ResponseCache] only writes the Cache-Control and Vary headers; it doesn't generate an ETag and doesn't decide on 304. The response caching middleware's source has a ContentIsNotModified check: if the cached response has an ETag or Last-Modified that matches the request, it returns 304. But that ETag still has to come from your endpoint. The exception is the static file middleware: UseStaticFiles puts an ETag and Last-Modified on every file itself and returns the 304 on its own:
$ curl -i localhost:17525/ng/index.html
HTTP/1.1 200 OK
ETag: "1dd6f286aabc52e"
Last-Modified: Sun, 08 Nov 2026 02:25:27 GMT
$ curl -i localhost:17525/ng/index.html -H 'If-None-Match: "1dd6f286aabc52e"'
HTTP/1.1 304 Not Modified
ETag: "1dd6f286aabc52e"
Notice there is no Cache-Control, so the browser may apply the heuristic freshness rule above. For static files, set the lifetime explicitly with StaticFileOptions.OnPrepareResponse. Storing whole responses on the server is the output cache's job and the topic of the sixth post in this series.
The Angular Side: Browser Cache, HttpClient and an Interceptor
In Angular 22, HttpClient uses fetch by default (withXhr switches it to XHR (XMLHttpRequest)); both go through the browser's HTTP cache. To see this we wrote a small page that spins up a real HttpClient with createApplication, bundled it with esbuild in JIT (Just-in-Time) mode and ran it in headless Chrome from the API's own origin. Each line shows the status HttpClient saw for a request and the transfer size from Resource Timing:
menu #1: status=200 network=1 lastTransfer=379B
menu #2: status=200 network=2 lastTransfer=0B
book #1: status=200 network=1 lastTransfer=381B
book #2 no-cache: status=200 network=2 lastTransfer=300B
cart #1: status=200 network=1 lastTransfer=336B
cart #2: status=200 network=2 lastTransfer=336B
cart #3 interceptor: status=200 network=1 lastTransfer=336B
cart #4 interceptor: status=200 network=1 lastTransfer=336B
server: {"/api/books/42 200":1,"/api/books/42 304":1,"/api/categories/menu 200":1,"/api/me/cart 200":3}
The answer to the first question is on the book #2 line. We sent that request with the { cache: 'no-cache' } fetch option; the browser sent If-None-Match, the server returned 304 (the counter shows "/api/books/42 304":1), and the transfer was just 300 bytes of headers. Yet HttpClient saw a 200 and the full body: the browser merges the 304 with the copy in its cache and hands it to the application as a 200. An if in Angular code that tries to catch a 304 never runs. menu #2 never touched the network: while max-age=60 holds, the transfer is 0 bytes and the server saw a single 200.
Because the cart returns private, no-cache without an ETag, every request went to the server with a full body. If you want a short-lived client-side cache for such a frequently repeated read, an interceptor switched on per request, following the HttpContextToken approach in the Angular docs, is enough:
import { HttpContextToken, HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { of, tap } from 'rxjs';
// how long a GET response may live in memory; 0 means no client cache
export const CACHE_TTL_MS = new HttpContextToken<number>(() => 0);
interface Entry {
response: HttpResponse<unknown>;
expiresAt: number;
}
const store = new Map<string, Entry>();
export const memoryCacheInterceptor: HttpInterceptorFn = (req, next) => {
const ttl = req.context.get(CACHE_TTL_MS);
if (req.method !== 'GET' || ttl <= 0) {
return next(req);
}
const key = req.urlWithParams;
const hit = store.get(key);
if (hit && hit.expiresAt > Date.now()) {
return of(hit.response.clone());
}
return next(req).pipe(
tap((event) => {
if (event instanceof HttpResponse) {
store.set(key, { response: event, expiresAt: Date.now() + ttl });
}
}),
);
};
// call on logout so the next user never sees the previous user's data
export function clearHttpCache(): void {
store.clear();
}
http.get('/api/books/42', { cache: 'no-cache' });
const ttl = new HttpContext().set(CACHE_TTL_MS, 30_000);
http.get('/api/me/cart?v=2', { headers: { Authorization: 'Bearer alice' }, context: ttl });
The cart #3 and #4 lines show it: Resource Timing has a single network request for that URL, and it is the third 200 on the server. When is it unnecessary? If the server sends a proper max-age and ETag, the browser already does this job, even across page reloads; the Map in the interceptor is lost on reload and knows nothing of the server's invalidation decisions. Use an interceptor for a third-party API whose headers you can't change, or to collapse several components on the same screen asking for the same data into one request; on your own API, fix the headers first.
In Production
For measurement, count the requests that reach the server by status code, like our /stats endpoint. A rising 304 ratio means the ETag works; but a 304 is still a request: the server has to authenticate it, read the version and write a response. The real gain comes from requests that never arrive while max-age holds, and those don't show up in the counter; look at the hit ratio in the CDN or proxy logs.
You can't take back a max-age you gave the browser. If you give the menu an hour and publish a wrong category, some users will see it for that hour. That's why a short max-age plus ETag for the browser and a longer s-maxage with purge capability for the proxy is a good split. For assets that never change, the opposite applies: put a hash in the file name and give it a very long lifetime; the details are in the upcoming Browser APIs and PWA (Progressive Web App) series in the Frontend category.
If several instances sit behind a load balancer, every instance must produce the same ETag. A content hash and a database version meet that requirement; an instance's start time or an in-memory counter don't, and every time the request lands on a different instance you get a 200 instead of a 304.
Common Pitfalls
Putting public on a per-user response. In our test Bob got Alice's cart; the Authorization header didn't protect it, because public deliberately removes that protection.
Forgetting Vary. Every response that changes by a request header, such as language, currency or compression, must list that header in Vary; otherwise the first user's representation goes to everyone.
Reading no-cache as "don't store". "Don't store" is no-store; no-cache stores but asks every time.
Forgetting the headers on 304. If you write ETag and Cache-Control only in the 200 branch, the cache can't update the freshness information of its stored copy.
Writing no header. A response without Cache-Control but with Last-Modified is open to heuristic caching by the browser.
Expecting a 304 in Angular. HttpClient sees the 200 the browser assembles; you only see the 304 in the server log or the browser's network tab.
A Small Experiment
★☆☆ Request the menu through nginx, then stop the API and request it again before 5 minutes pass. Does a response come back? Cut s-maxage to 10 seconds and repeat; what do you see without nginx's proxy_cache_use_stale setting?
★★☆ Add compression based on Accept-Encoding to the book endpoint (AddResponseCompression) and look at the response's Vary header. How many separate entries does the proxy create for a single book?
★★★ Extend memoryCacheInterceptor so that an expired entry sends a conditional request with If-None-Match instead of a full request. Is there any benefit when the browser already does the same thing? Compare the two approaches in the network tab.
In this post we moved the cache outside the server: the browser and the proxy trust the server's headers and either never send the request or settle for a bodiless 304. In the next post we bring the cache back to the server, but with whole responses: ASP.NET Core output cache policies, vary-by rules, tag-based eviction, resource locking and the Redis store.