⏱️ Lectura: 12 min
When Chrome shows (from disk cache) next to a file in the Network tab, that request never reached the server. The browser answered on its own, without opening a connection, because an HTTP header had told it minutes earlier how long it could trust what it already had stored.
📑 En este artículo
- TL;DR
- What HTTP Caching Is and Why It Matters
- How HTTP Caching Works in Detail
- Practical Examples
- Getting Started
- Real-World Use Cases
- Common Mistakes and Best Practices
- Comparison with Alternatives
- Going Deeper
- Frequently Asked Questions
- What’s the real difference between no-cache and no-store?
- How is an ETag generated in practice?
- Does Cache-Control only apply to GET requests?
- What happens if the CDN and the browser have different caching rules?
- Is it worth using ETag if I already have a long max-age?
- How do I test my caching setup without external tools?
- References
That header is called Cache-Control and, together with ETag, forms the HTTP caching mechanism that decides whether your server resends data the client already has. Understanding it well saves bandwidth, lowers perceived latency, and avoids the classic bug of unknowingly serving stale content.
TL;DR
- Cache-Control: max-age=60 tells the browser not to request the resource again for 60 seconds, without touching the network.
- An ETag is a hash of the content: if it hasn’t changed, the server responds with 304 and no body, saving bandwidth.
- no-cache doesn’t mean “don’t cache”: it forces revalidation on every request, but still allows a 304 response without resending data.
- no-store does block storage entirely: use it only for sensitive data that should never persist on disk.
- stale-while-revalidate serves stale content immediately while revalidating in the background, without blocking the user.
- The Vary header tells the cache to store separate versions based on Accept-Encoding or Authorization.
- RFC 9111 replaces the 2014 RFC 7234 as the current IETF standard for HTTP caching.
- A file with a hash in its name (app.a1b2c3.js) can carry max-age=31536000 and immutable with no risk of serving a stale version.
What HTTP Caching Is and Why It Matters
Think of a librarian who remembers which books they’ve already lent to each visitor. If you ask for the same book you borrowed yesterday, they don’t go back to storage to fetch it again: they ask if you still have it. That’s essentially HTTP caching: an agreement between client and server about how long a response stays valid before it needs confirmation.
The standard that defines this agreement is RFC 9111, published by the IETF, which replaces the 2014 RFC 7234. It specifies the exact rules for Cache-Control, ETag, Last-Modified, and how browsers, proxies, and CDNs interact with them.
HTTP caching isn’t a plugin or an external library: it lives in the protocol itself. Every response can declare its own caching rules, and every intermediary (the browser, a corporate proxy, a CDN) must respect them. When those rules are missing or wrong, the result is one of two opposite problems: servers receiving redundant traffic, or users seeing outdated data without knowing why.
How HTTP Caching Works in Detail
Cache-Control: The Header That Sets the Rules
This is a response header the server adds, and it accepts several directives that can be combined with commas. The most common are public (any cache, including CDNs, can store it), private (only the user’s browser, never a shared proxy), max-age=N (seconds the response is valid without revalidation), no-cache, no-store, must-revalidate, and immutable.
The name no-cache confuses almost everyone: it doesn’t prohibit storing the response. It requires the client to revalidate it against the server with a conditional request before reusing it. If nothing changed, the server responds with 304 without resending the body. no-store is the directive that actually prohibits storing any copy at all, not even for later revalidation: it’s reserved for sensitive data like payment states or session tokens.
ETag: The Content’s Fingerprint
An ETag is an identifier (usually a hash of the content) that the server attaches to the response. On the next request, the browser sends it back in the If-None-Match header. If the server computes the same hash, it knows the content hasn’t changed and responds with 304 Not Modified and no body, saving the full transfer even if the resource is several megabytes.
Last-Modified: The Time-Based Validator
This is ETag’s predecessor: the server sends the last modification date, and the client returns it in If-Modified-Since. It’s less precise because it only has second-level granularity, so two changes within the same second go unnoticed. It’s still useful as a fallback when generating a content hash is expensive.
sequenceDiagram
participant N as Browser
participant S as Server
N->>S: GET /styles.css
S-->>N: 200 OK, max-age=60
Note over N: stores the response in local cache
Note over N: second request 30s later
Note over N: cache still valid, zero network traffic
The diagram above shows the simple case: while max-age hasn’t expired, the browser doesn’t even try to contact the server. Once that window closes, conditional revalidation with ETag comes into play:
sequenceDiagram
participant N as Browser
participant S as Server
N->>S: GET /api/prices, If-None-Match=etag-abc123
alt content unchanged
S-->>N: 304 Not Modified, no body
else content changed
S-->>N: 200 OK, new ETag and full body
end
Practical Examples
The simplest example: an Express route that tells the browser not to request anything again for a minute.
const express = require('express');
const app = express();
app.get('/greeting', (req, res) => {
res.set('Cache-Control', 'public, max-age=60');
res.send('Hello, this response is cached for 60 seconds');
});
app.listen(3000);
The first request brings the full body. Over the next 60 seconds, any fetch() or browser reload of that same URL resolves locally: no request goes out to the network.
The second example adds ETag for data that changes frequently, where a long max-age wouldn’t make sense:
const crypto = require('crypto');
app.get('/api/prices', (req, res) => {
const data = JSON.stringify({ dollar: 3.75, updated: '2026-09-19' });
const etag = crypto.createHash('sha1').update(data).digest('hex');
res.set('ETag', '"' + etag + '"');
res.set('Cache-Control', 'no-cache');
if (req.headers['if-none-match'] === '"' + etag + '"') {
return res.status(304).end();
}
res.type('json').send(data);
});
With no-cache, the browser revalidates on every request, but if the dollar price hasn’t changed, the server responds with 304 and the endpoint avoids transferring the full JSON on every call.
For versioned static files (the pattern Webpack or Vite generate when building app.a1b2c3.js), the typical nginx configuration is much more aggressive:
location ~* \.(?:css|js|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
Getting Started
To try this out in your own Node.js project:
npm install express
Copy the first example above into a server.js file and start it with node server.js. Then check the headers without opening a browser:
curl -sI http://localhost:3000/greeting | grep -i cache-control
To confirm that ETag revalidation works, first request the resource and save the ETag header value from the response, then repeat the request sending it back in If-None-Match:
curl -sI http://localhost:3000/api/prices | grep -i etag
curl -s -o /dev/null -w "%{http_code}\n" \
-H 'If-None-Match: "value-copied-above"' \
http://localhost:3000/api/prices
If the second command prints 304, conditional revalidation is working. In the browser, the devtools Network tab shows the same thing: a row with status 304 and near-zero transfer size, or directly the (from disk cache) label when there wasn’t even a revalidation.
Real-World Use Cases
CDNs like Cloudflare or Fastly read these exact same headers to decide what to store on their edge servers, located geographically close to the user. A Cache-Control: public, max-age=3600 at the origin propagates to every edge node, so most visits never reach the original server.
Mobile apps with limited connectivity especially benefit from ETag on API endpoints: if the product catalog hasn’t changed since the last sync, the 304 response avoids re-downloading several megabytes of JSON over an intermittent 4G connection.
The hashed filename pattern (main.a1b2c3.css) combined with immutable solves the invalidation problem a different way: instead of waiting for a max-age to expire, each build generates a new filename when the content changes, so the browser can safely cache it forever.
💡 Tip: combining hashed files withimmutableand anindex.htmlserved withno-cacheis the standard SPA deployment pattern: the HTML always revalidates, the versioned assets never do.
Common Mistakes and Best Practices
The most frequent mistake is treating no-cache as a synonym for no-store. A team that thinks it’s disabling caching with no-cache is actually still letting the browser store the response, just with revalidation. To block storage entirely, the correct directive is no-store.
⚠️ Watch out: forgetting theVaryheader when a response changes based onAccept-EncodingorAuthorizationcan make a CDN serve one user’s compressed version to another user who doesn’t support it, or worse, serve a response meant for one authenticated user to a different one.
Another classic mistake is setting a high max-age on content that changes without warning, like a SPA’s main HTML. When the team deploys, some users keep seeing the old version for hours because their browser still trusts the cache. Phil Karlton summed it up decades ago: there are only two hard things in computer science, cache invalidation and naming variables.
Friction also shows up during local development: the browser aggressively caches 200 responses even after the server code has changed. The simple fix is to enable “Disable cache” in the devtools Network tab while the server is open, instead of debugging headers that actually work fine in production.
Comparison with Alternatives
| Mechanism | What It Does | When to Use It | Limitation |
|---|---|---|---|
| Cache-Control: max-age | Avoids any network request while the time hasn’t expired | Static assets, responses that rarely change | Risk of serving stale content if the expiration is miscalculated |
| Cache-Control: no-cache | Forces revalidation on every request, allows 304 | Data that changes often but supports quick verification | There’s always at least one network round trip |
| Cache-Control: no-store | Prohibits storing any copy | Sensitive data: payments, tokens, sessions | Zero caching benefit, always a full transfer |
| ETag + If-None-Match | Validates by content hash | APIs and resources where computing the hash is cheap | Requires computing and comparing the hash on every revalidation |
| Last-Modified | Validates by modification date | Files that already have a reliable timestamp | One-second granularity, less precise than ETag |
| stale-while-revalidate | Serves the stale copy while revalidating in the background | Content where a small lag is acceptable | The user may see outdated data for a few seconds |
Going Deeper
The stale-while-revalidate directive, defined in RFC 5861, replaces the traditional “valid or invalid” model with three states: fresh, stale but usable, and expired. With Cache-Control: max-age=60, stale-while-revalidate=30, during the first 60 seconds the response is served directly; during the following 30, the old copy keeps being served immediately while the client triggers a revalidation in parallel that updates the cache for next time.
The Vary header defines which part of the request is part of the cache key. Vary: Accept-Encoding tells a proxy to store a compressed copy and an uncompressed one separately. Without that header, a CDN could deliver the gzip version to a client that doesn’t support it, or mix up responses meant for different users if the cache key doesn’t include Authorization.
The distinction between private and shared caching also matters: private tells proxies and CDNs not to store that response, reserving it only for the end user’s browser. It’s the correct directive for any endpoint that returns session-specific data, even if the content itself isn’t confidential.
HTTP/2 and HTTP/3 don’t change any of these rules: caching semantics are a layer entirely separate from transport. A server serving HTTP/3 over QUIC still uses exactly the same Cache-Control and ETag headers as one on HTTP/1.1.
💭 Key point: HTTP caching only reduces server work when it hits: every 304 missed because of a badly computed ETag still costs a full database query, even though the body never travels over the network.
flowchart TD
A["Browser"] -->|"cache miss"| B["CDN edge"]
B -->|"cache miss"| C["Origin server"]
C -->|"200 OK + Cache-Control"| B
B -->|"200 OK + Cache-Control"| A
That hierarchy explains why a single misconfigured header at the origin propagates everywhere: if the origin server sends Cache-Control: public, max-age=3600, both the CDN edge and the final browser respect that same one-hour window, with no one having to configure anything separately at each layer.
📖 Summary on Telegram: View summary
Your next step: open your browser’s devtools, go to the Network tab, reload any site you use often, and count how many rows show (from disk cache) or status 304 instead of 200.
Frequently Asked Questions
What’s the real difference between no-cache and no-store?
no-cache allows storing the response but requires revalidating it before reuse, so it can end up as a 304 without resending the body. no-store prohibits storing any copy at all, not even for later revalidation.
How is an ETag generated in practice?
The most common approach is a hash (MD5, SHA-1) of the response content, or an internal version identifier if the resource already has one, like the last-write timestamp in the database.
Does Cache-Control only apply to GET requests?
It applies to any method, but it only makes practical sense on idempotent responses like GET and HEAD. Responses to POST are almost never cached, unless the server explicitly indicates it should be.
What happens if the CDN and the browser have different caching rules?
A CDN can override or ignore certain directives depending on its configuration (for example, enforcing its own minimum TTL), but by default both respect the same Cache-Control header sent by the origin.
Is it worth using ETag if I already have a long max-age?
Yes, as a fallback for when the user forces a manual refresh (Ctrl+Shift+R) or when the max-age expires: the ETag avoids retransferring the full body even though the revalidation does touch the network.
How do I test my caching setup without external tools?
Use curl -I to inspect headers, and repeat the request with the If-None-Match header copied from the previous ETag to confirm the server responds with 304 instead of 200.
References
- MDN: Cache-Control: complete reference for all directives and their syntax.
- MDN: ETag: how it’s generated, compared, and used together with If-None-Match.
- RFC 9111: HTTP Caching: the current IETF standard that replaces RFC 7234.
- RFC 5861: defines the stale-while-revalidate and stale-if-error extensions.
- web.dev: Prevent unnecessary network requests with the HTTP Cache: a practical, performance-oriented guide.
📱 Enjoying this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day. @programacion
0 Comments