Embercode
✕
How the Internet WorksLesson 14 of 15

45 min read

HTTP caching headers

Why this matters

A stylesheet is downloaded once and reused on every page of a site. Without that reuse, every page view would download the same files again, and pages would load slowly on a slow connection. Caching is the reason a site feels fast on the second visit.

Caching also causes a common kind of bug. You change a file on the server, and the browser keeps showing the old version. The browser lesson mentioned this symptom and the quick fixes. This lesson explains the headers that decide when the browser reuses a copy, so you can predict the behaviour and set the headers on purpose. A wrong header can also keep a private page in a shared cache, where another user can see it. That is a security problem, not only a display problem.

The headers in this lesson are Cache-Control, ETag and Last-Modified. The HTTP request and response lesson listed them. The browser lesson showed the simplest case, a max-age limit. This lesson covers the rest.

The idea: a library loan

Think of a library book. When you borrow it, the book has a due date. Until the due date you read your own copy and never call the library. After the due date, you ask the library a question before you keep reading: "Has this edition changed since the copy I hold?" If the answer is no, the library says so and you keep your copy. If the answer is yes, you borrow the new edition.

The browser handles a stored file the same way. The server sets two things:

  • A lifetime, which is the due date. Until it passes, the browser reuses its copy without asking. The header is Cache-Control.
  • A version check, which is the question about the edition. When the lifetime has passed, the browser asks the server whether the file has changed. The headers are ETag and Last-Modified.

Every caching decision in this lesson is one of these two steps. Keep both in mind as you read.

Cache-Control: the server's instructions

Cache-Control is a response header that carries a list of instructions. Each instruction is a directive, a word or a name=value pair, separated by commas. For example:

Cache-Control: max-age=120, must-revalidate

The table lists the directives you will meet most often.

DirectiveWhat it tells the browser
max-age=NReuse the stored copy for N seconds without asking the server.
no-cacheThe browser may store a copy, but must ask the server before each reuse.
no-storeDo not store any copy at all.
must-revalidateOnce the copy is stale, do not reuse it without asking.
publicAny cache may store the response, including a shared cache.
privateOnly the user's own browser may store the response.
s-maxage=NLike max-age, but only for shared caches.
immutableThe file at this URL will never change, so do not check it again while it is fresh.

Two directives cause the most confusion. The first is no-cache. Its name says "do not cache", but it means "store it, but check every time". A browser with a no-cache copy still keeps the copy, and it still uses the copy when the server answers "not modified". The directive that forbids storing is no-store.

The second confusion is public and private. A page with a user's bank balance should never be public, because a shared cache, such as a content delivery network (a service that keeps copies of files near users), could keep the page and serve it to someone else who asks for the same address. Use private for pages that depend on the user, and no-store for pages that must never be kept.

Look at the headers that our test server sent for a stylesheet. The Server and Date lines are removed to keep the output short.

HTTP/1.1 200 OK
Content-Type: text/css
Cache-Control: max-age=120
ETag: "v1"
Last-Modified: Mon, 01 Jun 2026 09:00:00 GMT
Content-Length: 22
 
body { color: teal; }

The max-age=120 line says the copy is good for 120 seconds. The ETag and Last-Modified lines give the browser what it needs to ask later, which is the next two sections.

Freshness: when a copy still counts

A stored copy is fresh while its age is below its lifetime, and stale after that. The age is the time since the browser stored the copy. The lifetime is the max-age value. A fresh copy is reused with no request at all. A stale copy triggers a question to the server.

The output below comes from a small client that follows these rules. It stores each response, reuses it while fresh, and asks the server when it is stale. The time is a simulated number, so the test runs at once, and the server logs show each request it really received. The client sends the saved ETag back in an If-None-Match header when it asks, which the next section explains.

The client asks for /style.css at five times, 0, 60, 119, 120 and 200 seconds.

t=  0  /style.css      asked server, got 200
t= 60  /style.css      reused from cache (no request)
t=119  /style.css      reused from cache (no request)
t=120  /style.css      asked server, got 304, reused copy
t=200  /style.css      reused from cache (no request)

Work through the timings. At 60 the copy is 60 seconds old, under the 120-second lifetime, so it is fresh and there is no request. At 119 the age is 119, still fresh. At 120 the age equals the lifetime. The rule is "less than", so the copy is stale. The browser asks, the server says the file has not changed, and the browser keeps its body and starts a new lifetime from 120. At 200 the age since the new start is 80, so the copy is fresh again.

The boundary matters. A copy that is exactly as old as its lifetime is already stale. If you plan a test around a limit, check at one second below the limit and at the limit itself.

A shared cache, such as one on a company network or a content delivery network, can also keep a copy and report how long it has been stored in an Age header. The browser adds that number to its own estimate. This lesson does not test shared caches, so the examples only use the browser's view.

Next, the same client on two other kinds of response.

t=  0  /private.html   asked server, got 200
t= 10  /private.html   asked server, got 200

/private.html was sent with Cache-Control: no-store. The client stored nothing, so it asked again at 10 seconds. The server saw two full requests.

t=  0  /index.html     asked server, got 200
t=  5  /index.html     asked server, got 304, reused copy

/index.html was sent with Cache-Control: no-cache. The copy has a lifetime of zero, so it is stale at once. At 5 seconds the client asked, the server said "not modified", and the client reused its body. The copy was stored, but every use of it required the question.

The curl test below sent each request by hand, and the server logged the conditional headers it received. Each line is (path, If-None-Match, If-Modified-Since):

('/style.css', None, None)
('/style.css', '"v1"', None)
('/app.js', None, 'Mon, 01 Jun 2026 09:00:00 GMT')
('/index.html', 'W/"home-1"', None)

The first request for /style.css has no conditional header, so the server sends the full file. The second request carries the stored ETag. A request with no conditional header and a full 200 response is always a full download.

Validation: asking has it changed

When a copy is stale, the browser can ask the server a question instead of downloading the file again. The question is a conditional request. It contains a condition that the server checks before it sends a body. If the condition shows that nothing has changed, the server answers 304 Not Modified, with no body. The browser then reuses the body it already has.

This saves bandwidth, because the response is a few hundred bytes of headers rather than the whole file. It also keeps the file up to date, because the server checks the real version each time.

ETag: a label for one version

An ETag (entity tag) is a label the server gives to one version of a resource. It is usually a hash of the content (a short fingerprint computed from it) or a version number. The server sends it in the response:

ETag: "v1"

The browser stores the label with the copy. When the copy is stale, the browser sends the label back in an If-None-Match header. The name means "send me the file only if none of these labels matches the current one."

We tested this with curl, a command-line tool that sends requests. The first request returns the file. The second request sends the label back.

curl -i http://127.0.0.1:8768/style.css
curl -i -H 'If-None-Match: "v1"' http://127.0.0.1:8768/style.css

Output of the second request:

HTTP/1.1 304 Not Modified
Cache-Control: max-age=120
ETag: "v1"
Last-Modified: Mon, 01 Jun 2026 09:00:00 GMT
Content-Length: 0

The server checked the label, saw that it matched, and sent no body. The headers in the 304 response tell the browser to keep the old copy and start a new lifetime. Note that the 304 still repeats Cache-Control and ETag, so the browser can update its record.

Strong and weak labels

An ETag can be strong or weak. A strong label, such as "v1", means the bytes are identical. A weak label starts with W/, such as W/"home-1". It means the content is equivalent, even if the bytes differ slightly. For example, a server may add a space to a page, which changes the bytes but not what the page means.

For a conditional request with If-None-Match, the server compares labels with the weak rule. So a weak label can still produce a 304. You rarely need to choose between the two when you write a small server. Know that a W/ label is a promise about meaning, not bytes, and that it does not affect whether the browser reuses a copy.

Last-Modified: a date

Last-Modified gives the time the file last changed:

Last-Modified: Mon, 01 Jun 2026 09:00:00 GMT

The browser sends the same date back in an If-Modified-Since header. If the file has not changed since that date, the server answers 304.

We tested the date rule on /app.js, which has no ETag:

curl -i -H "If-Modified-Since: Mon, 01 Jun 2026 09:00:00 GMT" http://127.0.0.1:8768/app.js

Output:

HTTP/1.1 304 Not Modified
Last-Modified: Mon, 01 Jun 2026 09:00:00 GMT
Content-Length: 0

A date has a resolution of one second, and it is weaker than a label. A file that changes twice within the same second has the same date both times, so the browser cannot tell the two versions apart. For that reason, an ETag is the better check when you control the server.

When both headers are present, the server checks the ETag first and ignores If-Modified-Since. Our test server follows the same order. The /style.css response above has both headers, and the client sent only the label.

Heuristic freshness: when the server sends no lifetime

Some responses have no Cache-Control at all. The browser then has no instruction about how long to keep the copy. Many browsers guess a lifetime from the Last-Modified date. The standard that describes HTTP caching, RFC 9111, suggests a guess of 10 percent of the time since the file last changed.

For example, suppose a file was last changed 50 days ago. A 10 percent guess gives 0.10 times 50, which is 5 days. The browser may then reuse its copy for up to five days without asking. Browsers differ in the exact rule, so do not rely on the number.

Lesson 5 used app.js as an example of a file with no Cache-Control header. That lesson said such a file is downloaded again on every visit. That is true when the response has no Last-Modified date either. If the response has a date, a browser may reuse it for a while without asking.

The safe rule is to set Cache-Control yourself on every file. Do not depend on the guess.

Setting the headers for a real site

The right headers depend on what a file is. Three common cases cover most sites.

Versioned assets. A file whose name contains a hash, such as app.3f9a1c.js, never changes, because a new version gets a new name. Give it a long lifetime and immutable:

Cache-Control: public, max-age=31536000, immutable

The value 31536000 is one year in seconds. A new release uses a new name, so no stale copy can cause trouble.

The HTML page. The page is the file that names the other files. If the page itself is cached for a long time, users keep the old page, and the old page points at files that may no longer exist. Use a short lifetime or no-cache, so the page is checked often, and let the versioned files do the caching.

Cache-Control: no-cache
ETag: "page-8"

Private data. A page or API response that depends on the logged-in user should not be stored by shared caches. Use private, or no-store for anything sensitive:

Cache-Control: no-store

These three patterns match the security advice from the session lesson. A stored copy of a logged-in page is a copy of someone's data.

Common mistakes

1. Reading no-cache as no-store. A no-cache response is stored and checked before each use. A no-store response is never stored. If you want a page never to reach the disk, write no-store. Writing no-cache expecting privacy is a mistake that leaves a copy on the device.

2. Marking user data as public. A public response may be kept by a shared cache and served to other people. Use private or no-store for anything tied to one user.

3. A long lifetime on the HTML page. After a deploy, users keep an old page that asks for old script files. Those files may be gone, and the browser shows an error or a blank page. Keep the HTML short-lived, and use hashed names for everything else.

4. Changing a file but keeping its name. A file served as app.js with max-age=31536000 will stay old in every browser that stored it, for a year. Hashed names solve this. The browser lesson described the same fix.

5. Different ETags on different servers. If a site runs on several servers behind a load balancer (a device that spreads requests across them), each server may compute a different ETag for the same file. A browser that stored the label from one server sends it to another. The labels do not match, so the server sends a full 200 every time. The cache still works, but the saving is lost. Make every server produce the same ETag from the same content, or use Last-Modified for that file instead.

6. Forgetting Vary. A server may send gzip-compressed text (text shrunk with the gzip format) to some clients and plain text to others. A cache that stores one of those responses and serves it to a client that asked for the other form returns garbage or a wrong encoding. The header Vary: Accept-Encoding tells caches to keep one copy for each encoding. Without it, a shared cache can mix them up.

7. Assuming the browser shows you the server's response. In Chrome's Network tab, the Size column of a request can show (disk cache) or (memory cache). The request did not reach the server at all. A status of 304 in the same tab means the server was asked, and it said nothing had changed. Both look fast, but they are different events.

A debugging walkthrough

Suppose you deployed a new style.css, and the page still shows the old colours. Work through these steps.

  1. Open the Network tab and reload the page. Find the stylesheet row. Look at the Status and Size columns. If the Size column says (disk cache) or (memory cache), the browser did not ask the server. The problem is the lifetime. If the status is 304, the server was asked and said the file was unchanged. If the file still shows the old colours, the server is serving the old file.
  2. Read the response headers. Look for Cache-Control, ETag and Last-Modified. A max-age of 31536000 with an unchanged file name explains a stale copy. A missing ETag and Last-Modified explains why there is no way to check.
  3. Read the request headers. If the If-None-Match header is missing on a stale copy, the browser has no label to send. Check that the response had an ETag in the first place.
  4. Ask the server directly. Run curl -i on the file's address. The response shows what the server sends now, without any browser cache. If curl shows the new file, the server is correct and the browser is holding the old copy. If curl shows the old file, the deploy or the server is at fault.
  5. Compare the ETags. Run curl -i twice, or against each server if you have several. Different ETags for the same content point to the problem in mistake 5.

Variations and edge cases

Shared caches. A content delivery network stores responses near users. The directive s-maxage sets a lifetime for those caches only, so the browser can use a different value. A shared cache will not store a response to a request that has an Authorization header (a request header that carries login details) unless the response says so, for example with public. Check the rules for the network you use.

Service workers. A service worker is a script that runs in the browser and can answer requests itself. A site that uses one may serve files from its own store, so the headers in this lesson do not apply until the worker asks the network. The worker's code decides when to ask.

Reloads. A normal reload and a hard refresh do not always follow the same rules. A hard refresh asks the server for every file, which is useful when debugging. Do not use it as a test of the real caching behaviour, because real visitors do a normal visit.

Query strings. A file with ?v=2 at the end is a different address from the same file with ?v=1. Caches store each address separately. Changing the query string is a way to force a new download, but a hashed file name is more reliable, because proxies and some shared caches treat query strings differently.

Practice

Exercise 1. A response has Cache-Control: max-age=300 and was stored at time 1000. When does the browser reuse it without asking: at 1200, at 1300 and at 1301?

Show the answer

At 1200 the age is 200, which is less than 300, so the copy is fresh and reused. At 1300 the age is exactly 300, which is not less than 300, so the copy is stale and the browser asks. At 1301 the age is 301, also stale.

Exercise 2. You are writing the headers for a page that shows one customer's order history. Which directive do you use, public, private or no-store, and why?

Show the answer

Use private or no-store, not public. public would allow a shared cache to store the page and serve it to someone else. private keeps it in the customer's own browser. no-store keeps no copy at all, which is the safest choice for sensitive data.

Exercise 3. A file has no Cache-Control header, and its Last-Modified date is 40 days ago. Using the 10 percent guess, how long might a browser reuse its copy without asking?

Show the answer

Ten percent of 40 days is 4 days. A browser that uses this guess may reuse the copy for up to four days. The actual rule varies by browser, so set Cache-Control yourself.

Exercise 4. In the client test, the request at 120 seconds asked the server and got 304, and the request at 200 seconds reused the copy. Why did the request at 200 not ask the server?

Show the answer

The 304 at 120 started a new lifetime of 120 seconds, from time 120. At 200 the age is 80, which is less than 120, so the copy is fresh and reused with no request.

Exercise 5. Two servers hold the same style.css file. Server A sends ETag: "abc" and server B sends ETag: "xyz". A browser stored the file from server A and then asks server B. What happens, and how do you fix it?

Show the answer

Server B compares "abc" with its own label "xyz". They do not match, so server B sends the full file with a 200, even though the content is identical. Every request that reaches the other server is a full download. The fix is to make both servers compute the same ETag from the same content, or to rely on Last-Modified for this file.

Summary

  • Cache-Control tells the browser how long a copy stays fresh. max-age gives the lifetime in seconds.
  • A fresh copy is reused with no request. A copy exactly as old as its lifetime is stale.
  • no-cache means store and check every time. no-store means never store.
  • ETag and If-None-Match let the browser ask whether one version has changed. The server answers 304 with no body when it has not.
  • Last-Modified is a weaker date check. When both checks are present, the ETag wins.
  • Set the headers on purpose: long lifetimes for hashed files, short ones for the HTML page, and private or no-store for user data.

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Cookies, sessions and login state