Pragma: no-cache and Other Legacy Cache Headers

Problem Statement

A response carries four caching headers: Cache-Control: no-cache, no-store, must-revalidate, Pragma: no-cache, Expires: 0 and Expires: -1. It looks thorough. Three of the four contribute nothing, one of them is self-contradictory, and the set as a whole makes it genuinely difficult to say what the intended behaviour was.

Prerequisite Concepts

Step-by-Step Resolution

Step 1 — Inventory what is actually being sent

curl -sI https://example.com/account \
  | grep -iE 'cache-control|pragma|expires|vary|surrogate-control'

Legacy headers cluster on authenticated and dynamic routes, because those are the ones whose snippets were copied from anti-caching advice.

Step 2 — Establish which headers still do anything

What each legacy header does under RFC 9111Only one of the commonly stacked legacy headers has defined behaviour on a response, and it is overridden whenever Cache-Control carries a lifetime.Defined on a responseEffect todayPragma: no-cacheignored entirelyExpires: 0invalid date, treated as expiredExpires: -1invalid date, treated as expiredExpires: valid dateignored when max-age is presentCache-Controlthe only line that is read
Four legacy headers, one with any effect

Pragma is the clearest case. RFC 9111 defines it as a request header only; on a response it has no meaning and no cache consults it. It costs bytes on every response and misleads every reader.

Step 3 — Work out what the stack was trying to say

This is the step that matters, because deleting headers without replacing them is worse than leaving them. Three intents account for almost every legacy stack:

What the stack was probably expressingLegacy anti-caching headers are almost always reaching for one of three distinct behaviours, and each is expressible in a single modern directive.Must the body never touch storage?yesno-storenoMust every reuse be revalidated?yesno-cachenoIs a short lifetime acceptable?yesmax-age with a validatornoThe stack had no coherent intent
Three intents behind every legacy stack

The fourth outcome is common and worth naming: a stack assembled by accretion, where each header was added by a different person solving a different symptom, and no single intent exists to preserve.

Step 4 — Replace with one directive

# Before: four headers, one of them contradictory
# add_header Cache-Control "no-cache, no-store, must-revalidate";
# add_header Pragma "no-cache";
# add_header Expires "0";

# After: one line stating the intent
location /account/ {
    add_header Cache-Control "private, no-store" always;
}

For content that should be revalidated rather than never stored, the modern equivalent is a validator and a zero lifetime, which keeps the transfer saving that no-store throws away:

location /dashboard/ {
    add_header Cache-Control "private, no-cache" always;
    etag on;
}

Step 5 — Verify the outcome is unchanged

Simplification must not change behaviour, so compare the cache outcome before and after:

# Two requests, checking whether anything is stored and reused
curl -sI https://example.com/account | grep -iE '^age|cache-control'
sleep 2
curl -sI https://example.com/account | grep -iE '^age|cache-control'

An absent Age on both requests confirms nothing is being stored by a shared cache, which is what the original stack was reaching for. If Age appears where it did not before, the replacement is weaker than the original and needs revisiting.

Bytes of caching headers per responseA legacy stack costs a meaningful number of bytes on every response, repeated across every request, in exchange for behaviour a single line already provides.Legacy stack118bytes four headersSingle directive34bytes one headermultiplied by every response the site serves
Caching header bytes on one response

Step 6 — Decide what to do about the reviewer who wants them back

Removing a legacy stack reliably attracts a question, and the question is reasonable: the headers looked like protection and now they are gone. Two facts usually settle it.

The first is that Pragma is defined by the specification as a request header. It is not that browsers have stopped honouring it on responses; it never had a defined meaning there. Anything relying on it was relying on implementation-specific behaviour in software that has not shipped for many years.

The second is that the replacement is strictly stronger when the intent was no-store. The legacy stack combined no-cache and no-store on the same line, which is contradictory enough that different caches resolved it differently; a single no-store is unambiguous everywhere. Where the intent was revalidation rather than prohibition, the replacement adds a validator and therefore saves bandwidth the old stack was discarding.

Writing both points into the change description once saves repeating the conversation, and gives whoever reviews the next legacy stack somewhere to start.

Expected Output / Verification

A cleaned-up response carries exactly one caching header whose meaning is unambiguous, and behaves identically to the stack it replaced:

HTTP/2 200
Cache-Control: private, no-store

Where a lifetime is intended, the equivalent is equally short:

HTTP/2 200
Cache-Control: public, max-age=0, s-maxage=600
ETag: "9f21-8c4"

Edge Cases

  • A framework adding Pragma automatically. Several server frameworks append it to responses they consider dynamic, so it can reappear after being removed from application code. Check the framework’s own defaults.
  • Expires on a response with no Date. The freshness calculation needs both. An Expires value without a Date header is unreliable across caches, which is another reason to prefer a relative lifetime.
  • Removing headers without replacing them. A response with no caching header at all is not uncached — it is subject to heuristic freshness, which may be considerably more permissive than the stack it replaced.
  • Intranet proxies that genuinely predate Cache-Control. Rare but not extinct in some corporate environments. If a specific deployment requires it, keep the legacy header on that route only, with a comment explaining why.
  • Surrogate-Control mistaken for a legacy header. It looks similar and is often deleted alongside the genuinely obsolete ones, but it is actively consumed by CDNs and removing it changes edge behaviour.
  • Security scanners flagging the absence. Some automated checks still expect Pragma: no-cache on authenticated pages. That is a finding about the scanner, not about the response, and is worth documenting rather than complying with.

Frequently Asked Questions

Does Pragma: no-cache do anything on a response?

No. Pragma is defined as a request header in HTTP/1.0 and RFC 9111 gives it no meaning on a response. A cache reading a response ignores it entirely, so it is pure noise on a modern site.

Is Expires: 0 valid?

It is not a valid HTTP-date, but the specification says an invalid Expires value must be treated as already expired, so it does achieve the intended effect. Cache-Control: no-store or max-age=0 states the same thing without relying on an error path.

Should legacy headers be kept for old clients?

The clients they were written for — HTTP/1.0 proxies without Cache-Control support — are effectively extinct on the public internet. Keeping them adds bytes to every response and obscures which directive is actually in force.

Why do these header stacks keep reappearing?

Because they are copied from long-lived answers and framework defaults written when HTTP/1.0 caches were still common. The stack looks thorough, which is why it survives review even when most of it is inert.


Back to Header Stacking and Directive Precedence