Back to HTTP Headers

Expires caching response

An HTTP/1.0 header that sets an absolute expiry date for a cached response. Superseded by Cache-Control but still widely sent.

What it does

Expires sets an absolute date and time after which the response is considered stale. Any cache — browser, CDN, proxy — that holds this response will treat it as expired after that timestamp and must revalidate or fetch fresh before serving it again.

It's an HTTP/1.0 header that predates Cache-Control. In modern HTTP, Cache-Control: max-age has superseded it for the same purpose. But Expires is still sent by virtually every CDN and web framework for legacy compatibility, and understanding it still matters.

Syntax

Expires: <http-date>

The date must be in RFC 7231 HTTP date format — always GMT, never a local timezone:

Expires: Thu, 01 Jan 2026 00:00:00 GMT
Expires: Wed, 21 Oct 2026 07:28:00 GMT

A value of 0 or a date in the past means the response is already expired (effectively no-cache):

Expires: 0

How it interacts with other headers

Cache-Control: max-age overrides Expires. When both are present, max-age wins for HTTP/1.1 clients. Expires is only consulted when Cache-Control is absent — which is increasingly rare.

Date + Expires = effective max-age. The freshness lifetime a cache computes from Expires is Expires - Date. If the server sent Date: Mon, 01 Jan 2026 00:00:00 GMT and Expires: Mon, 01 Jan 2026 01:00:00 GMT, the effective max-age is 3600 seconds.

Age reduces remaining freshness. A CDN adds Age: 1800 to indicate the response has been cached for 30 minutes. A client computing freshness from Expires still uses the absolute timestamp — not the Age — but the Age tells you how much of the freshness window is already consumed.

The clock skew problem

This is why Cache-Control: max-age was invented.

Expires uses an absolute timestamp. For it to work correctly, the server clock and client clock must be synchronised. If your server clock is 5 minutes fast, every response expires 5 minutes earlier than intended. If it's 5 minutes slow, responses stay fresh 5 minutes longer than you wanted.

max-age uses a relative duration in seconds — "fresh for 3600 seconds from when you received this." No clock synchronisation needed. The client's clock accuracy is irrelevant.

In 1997 when HTTP/1.1 introduced Cache-Control, NTP synchronisation was far less universal than today. Clock skew was a real, practical problem. Today it's less of an issue, but max-age is still the right choice.

When Expires still matters

Legacy HTTP/1.0 clients and proxies. Old caching proxies that don't understand Cache-Control still understand Expires. In practice, this is nearly extinct.

Explicit "already expired" signals. Setting Expires: 0 is a quick way to mark a response as immediately stale without crafting a full Cache-Control header. Though Cache-Control: no-cache or Cache-Control: max-age=0 is preferred.

CDN passthrough. Some CDNs use Expires internally for their own TTL calculations when Cache-Control doesn't include an s-maxage. Knowing it exists helps when debugging CDN caching behaviour.

Common mistakes and gotchas

Setting Expires to a fixed far-future date. This was a common pattern for "permanent" assets before content hashing was widespread: Expires: Thu, 31 Dec 2037 23:55:55 GMT. The problem: when you deploy new content, every browser with the old cache keeps serving the stale version until 2037. Always pair far-future caching with URL versioning (filename hashes).

Forgetting timezone. Expires must be GMT. Other timezones are invalid and may be parsed incorrectly or ignored. Your application's local timezone leaking into the header is a real bug.

Relying on Expires without Cache-Control. If only Expires is set, HTTP/1.1 clients might still apply heuristic caching rules or ignore it. Explicit Cache-Control is always safer.

Real-world examples

Far-future expiry (versioned asset):

HTTP/1.1 200 OK
Content-Type: text/css
Cache-Control: public, max-age=31536000, immutable
Expires: Fri, 20 Jun 2027 12:00:00 GMT

Both Cache-Control and Expires set — max-age wins for modern clients, Expires covers legacy.

Already expired (don't cache):

HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: no-cache, no-store
Expires: 0

Short TTL:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=300
Expires: Mon, 22 Jun 2026 12:05:00 GMT

FAQ

Should I still set Expires in 2026?

For public-facing web servers, yes — it doesn't hurt and ensures compatibility with any HTTP/1.0 intermediaries that might still exist. Your CDN almost certainly sets it automatically. But don't set only Expires — always set Cache-Control too.

What happens if Expires is in the past?

The response is treated as already stale. Any cache holding it must revalidate before serving it. This is equivalent to Cache-Control: max-age=0 or no-cache — the cache can still hold the response but can't serve it without checking with the origin first.

Can Expires be used on request headers?

No. Expires is response-only. On the request side, use Cache-Control: max-stale or Cache-Control: min-fresh to express staleness preferences.

Is there a maximum value for Expires?

The spec doesn't set a hard limit, but dates far beyond year 2038 can cause issues on 32-bit systems due to the Unix timestamp overflow (the "Year 2038 problem"). Practically, a date 1 year in the future is the standard "cache forever" convention for versioned assets.

Fun fact

Expires predates Cache-Control by a couple of years — it was part of HTTP/1.0 in 1996 (RFC 1945), while Cache-Control arrived with HTTP/1.1 in 1997 (RFC 2068). The original HTTP spec authors knew Expires was fragile (the clock skew issue was obvious in design), which is why Cache-Control was built to supersede it almost immediately. Yet Expires is so deeply embedded in web infrastructure that it will likely outlive everyone who remembers why it was deprecated.