Home › Networking › HTTP & RPC
algorithm low

HTTP caching semantics

Problem A client, proxy or edge server holds a copy of a fetched resource and must decide, for each new request, whether it may reuse the copy, must ask the origin whether it changed, or must refetch it.

The rules (RFC 9111) that decide whether a stored response is still fresh, and how a cheap conditional request revalidates it when it is not.

O(header length) per decision; a revalidation costs one round trip and, on 304, no body
httpcache-controlfreshnessetagconditional-requestrevalidation
Overview

Every response says how long it may be reused (max-age), how to check it later (ETag, Last-Modified) and who may keep it (private, no-store). A cache compares current_age with freshness_lifetime: younger means serve without asking; older means send If-None-Match/If-Modified-Since, and the origin answers 304 with headers only, so the stored body lives on. no-cache means "store, but always revalidate".

Key facts

Use when

  • Polling many remote feeds, APIs or package indexes: max-age as poll interval, validators, 304s.
  • Building a shared cache (reverse proxy, CDN, mirror), a browser or app HTTP stack, an SDK cache; tile and asset servers set long lifetimes.

Avoid when

  • Per-user or secret data in a shared cache: use private or no-store, not no-cache.
  • You need instant global invalidation: expiry alone cannot give it; add purge (Cache invalidation).

Deep dive

How it works

In: stored response (status, Cache-Control, Expires, Date, Age, validators, Vary), request and response times, now, shared or private cache · Out: Fresh, ServeStale, Revalidate{validators}, MustFetch or NoStore · State: the stored response and its two timestamps · Cost: O(header length), about 130 ns measured

  1. Storable? Not with no-store; not with private in a shared cache; not if the request had Authorization in a shared cache without public, s-maxage or must-revalidate (§3, §3.5). Needs an understood method (in practice GET, HEAD) and explicit freshness (public, max-age, Expires, s-maxage, or private in a private cache) or a heuristically cacheable status: 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501 (RFC 9110 §15.1).
  2. Vary (§4.1): reuse only if every request header named by Vary matches the stored request's; Vary: * never matches.
  3. Lifetime, first match (§4.2.1): s-maxage (shared), max-age, Expires - Date, else heuristic: a fraction (typically 10 %) of Date - Last-Modified (§4.2.2), else 0. Invalid values count as stale.
  4. Age (§4.2.3): apparent = max(0, response_time - Date); corrected = Age + (response_time - request_time); current_age = max(apparent, corrected) + (now - response_time).
  5. Fresh iff lifetime > current_age and no no-cache: serve, no request.
  6. Stale but allowed: stale-while-revalidate=N (RFC 5861 §3) serves for N more seconds while refreshing in the background, unless must-revalidate, or proxy-revalidate/s-maxage in a shared cache (§4.2.4).
  7. Revalidate (§4.3.1): send If-None-Match (MUST, when an ETag exists) and If-Modified-Since (SHOULD). No validator: plain refetch.
  8. Handle the answer (§4.3.3-4.3.4): 304 updates the stored headers (§3.2) and restarts age; 200 replaces the entry; 5xx may serve stale if stale-if-error allows (RFC 5861 §4). A successful unsafe request invalidates its target URI (§4.4).

Code

/// Cache-Control as (lowercase name, optional unquoted value); the first occurrence wins (RFC 9111 §4.2.1).
pub struct Cc<'a>(Vec<(String, Option<&'a str>)>);
impl<'a> Cc<'a> {
    pub fn parse(h: &'a str) -> Cc<'a> {
        let (mut v, mut start, mut q) = (Vec::new(), 0, false);
        for (i, c) in h.char_indices().chain(std::iter::once((h.len(), ','))) {
            match c {
                '"' => q = !q,
                ',' if !q => {          // a comma inside a quoted-string does not split
                    let d = h[start..i].trim();
                    start = i + 1;
                    if d.is_empty() { continue; }
                    let (n, val) = match d.split_once('=') {
                        Some((n, x)) => (n.trim(), Some(x.trim().trim_matches('"'))),
                        None => (d, None),
                    };
                    v.push((n.to_ascii_lowercase(), val));
                }
                _ => {}
            }
        }
        Cc(v)
    }
    pub fn has(&self, n: &str) -> bool { self.0.iter().any(|(k, _)| k == n) }
    /// delta-seconds: None if absent; invalid counts as 0 (= stale); capped at 2^31 (RFC 9111 §1.2.2).
    pub fn secs(&self, n: &str) -> Option<u64> {
        let (_, v) = self.0.iter().find(|(k, _)| k == n)?;
        Some(match v { Some(s) if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) =>
            s.parse::<u64>().unwrap_or(1 << 31).min(1 << 31), _ => 0 })
    }
}

pub struct Stored<'a> {
    pub status: u16, pub had_auth: bool,            // status; did the request carry Authorization
    pub cache_control: &'a str, pub vary: &'a str,
    pub expires: Option<u64>, pub date: Option<u64>, pub age: Option<u64>, // epoch seconds; an unparsable Expires = Some(0)
    pub etag: Option<&'a str>, pub last_modified: Option<(u64, &'a str)>,  // (epoch s, original text for If-Modified-Since)
    pub request_time: u64, pub response_time: u64,
}
#[derive(Debug, PartialEq)]
pub enum Decision {
    Fresh { age: u64, lifetime: u64 },
    ServeStale,                                      // stale-while-revalidate: serve now, refresh in the background
    Revalidate { if_none_match: Option<String>, if_modified_since: Option<String> },
    MustFetch,                                       // stored copy unusable and no validator
    NoStore,                                         // do not keep or reuse this response
}

const HEURISTIC: [u16; 12] = [200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501]; // RFC 9110 §15.1

pub fn current_age(r: &Stored, now: u64) -> u64 {                  // RFC 9111 §4.2.3
    let date = r.date.unwrap_or(r.response_time);
    let apparent = r.response_time.saturating_sub(date);
    let corrected = r.age.unwrap_or(0) + r.response_time.saturating_sub(r.request_time);
    apparent.max(corrected) + now.saturating_sub(r.response_time)
}

/// `vary_ok`: the new request's headers named by Vary match the stored request's. GET/HEAD only.
pub fn decide(r: &Stored, shared: bool, vary_ok: bool, now: u64) -> Decision {
    let cc = Cc::parse(r.cache_control);
    let (public, smax) = (cc.has("public"), cc.secs("s-maxage"));
    let explicit = public || cc.secs("max-age").is_some() || r.expires.is_some()
        || (shared && smax.is_some()) || (!shared && cc.has("private"));
    if cc.has("no-store") || (shared && cc.has("private"))
        || (shared && r.had_auth && !(public || smax.is_some() || cc.has("must-revalidate")))
        || !(explicit || HEURISTIC.contains(&r.status)) { return Decision::NoStore; }
    if !vary_ok || r.vary.split(',').any(|v| v.trim() == "*") { return Decision::MustFetch; }
    let date = r.date.unwrap_or(r.response_time);
    let lifetime = if let (true, Some(s)) = (shared, smax) { s }                   // §4.2.1: first match
        else if let Some(m) = cc.secs("max-age") { m }
        else if let Some(e) = r.expires { e.saturating_sub(date) }
        else if let Some((lm, _)) = r.last_modified { date.saturating_sub(lm) / 10 } // §4.2.2 heuristic: 10 %
        else { 0 };
    let age = current_age(r, now);
    if lifetime > age && !cc.has("no-cache") { return Decision::Fresh { age, lifetime }; }
    let strict = cc.has("must-revalidate") || (shared && (cc.has("proxy-revalidate") || smax.is_some()));
    if !strict && !cc.has("no-cache") && age - lifetime < cc.secs("stale-while-revalidate").unwrap_or(0) {
        return Decision::ServeStale;                                                // RFC 5861 §3
    }
    if r.etag.is_none() && r.last_modified.is_none() { return Decision::MustFetch; }
    Decision::Revalidate {                                                           // §4.3.1: ETag MUST be sent
        if_none_match: r.etag.map(String::from),
        if_modified_since: r.last_modified.map(|(_, s)| s.to_string()),
    }
}

fn main() {
    use Decision::*;
    // stored at t=1000 (Date 1000, no delay)
    let mk = |cc| Stored { status: 200, had_auth: false, cache_control: cc, vary: "", expires: None, date: Some(1000),
        age: None, etag: Some("\"v1\""), last_modified: None, request_time: 1000, response_time: 1000 };
    let reval = Revalidate { if_none_match: Some("\"v1\"".into()), if_modified_since: None };
    let c = mk("max-age=60, s-maxage=600");
    assert_eq!(decide(&c, true, true, 1100), Fresh { age: 100, lifetime: 600 });   // shared: s-maxage wins
    assert_eq!(decide(&c, false, true, 1100), reval);                              // private: max-age=60 is over
    let c = Stored { expires: Some(1300), ..mk("") };                              // lifetime = Expires - Date
    assert_eq!(decide(&c, false, true, 1299), Fresh { age: 299, lifetime: 300 });
    let c = Stored { last_modified: Some((0, "LM")), ..mk("") };                   // heuristic: 10 % of 1000 s
    assert_eq!(decide(&c, false, true, 1099), Fresh { age: 99, lifetime: 100 });
    let c = Stored { age: Some(500), request_time: 990, ..mk("max-age=600") };     // Age + response_delay (10)
    assert_eq!((current_age(&c, 1000), decide(&c, false, true, 1089)), (510, Fresh { age: 599, lifetime: 600 }));
    assert_eq!(decide(&mk("max-age=600, no-cache"), false, true, 1001), reval);    // no-cache = always revalidate
    assert_eq!(decide(&mk("max-age=600, no-store"), false, true, 1001), NoStore);
    assert_eq!(decide(&Stored { vary: "Accept-Encoding, *", ..mk("max-age=600") }, false, true, 1001), MustFetch);
    assert_eq!(decide(&mk("private, max-age=60"), true, true, 1001), NoStore);     // private in a shared cache
    assert_eq!(decide(&Stored { had_auth: true, ..mk("max-age=60") }, true, true, 1001), NoStore);
    assert_eq!(decide(&mk("max-age=60, stale-while-revalidate=30"), false, true, 1070), ServeStale);
    assert_eq!(decide(&Stored { etag: None, ..mk("max-age=60") }, false, true, 1100), MustFetch);
    assert_eq!(decide(&mk("max-age=abc"), false, true, 1001), reval);              // invalid max-age = stale
    assert_eq!(Cc::parse("no-cache=\"a, b\", MAX-AGE=5").secs("max-age"), Some(5));
}

Complexity and constants

  • Per decision: one pass over Cache-Control (tens of bytes) plus integer arithmetic; cost drivers are the parse and its allocations.
  • Per revalidation: one round trip; a 304 ends at the header section (RFC 9110 §15.4.5): headers only, order of hundreds of bytes.
  • Real caches add key hashing, Vary lookup and eviction (LRU and CLOCK), which dominate.

Efficiency

  • Cost: a decision took 127-144 ns on an Apple M4 Pro (this code, rustc 1.99.1 -O, 5 million decisions over 8 typical Cache-Control values, two runs, measured by me): 7-8 million/s on one core. A revalidation is bound by one round trip; a fresh hit costs no network.
  • Levers:
    • Poll no faster than max-age — requests per feed fall with the interval, 30 min against 5 min is 6× fewer (arithmetic) — harvesters, SDKs.
    • Conditional GET on every refetch — a 304 carries no body (RFC 9110 §15.4.5) — resources with a validator.
    • stale-while-revalidate — hides the revalidation round trip (RFC 5861 §3) — traffic dense enough to hit the window.
    • Request collapsing — one origin fetch per missing key (nginx proxy_cache_lock; Varnish by default) — shared caches under load.
    • Few Vary axes (avoid Vary: Cookie) — fewer variants per URL (RFC 9111 §4.1) — shared caches.
  • Naive → standard → state of the art:
    • Fetch every time · full body per poll.
    • Honour freshness, revalidate with validators · no request while fresh, headers-only 304 after.
    • Shared tier with stale-while-revalidate, stale-if-error, collapsing, purge · about one origin request per object per lifetime.

Implementation notes

  • Integer seconds; parse HTTP-dates to UTC epoch once, no local time zones (§4.2). An unparsable Expires means expired.
  • Cap delta-seconds at 2^31 on overflow (§1.2.2); the code does.
  • Store request_time and response_time with the entry; use a monotonic clock for now - response_time.
  • On 304, update stored headers except Content-Length (§3.2). Send the stored ETag verbatim, W/ included: If-None-Match compares weakly (RFC 9110 §13.1.2).
  • Heuristics differ: OkHttp applies 10 % only to URLs without a query; Traffic Server defaults heuristic_lm_factor to 0.10; Varnish falls back to default_ttl (2 min); CacheControl's opt-in LastModified heuristic caps at one day.

Pitfalls

  • no-cache read as "do not store": it forces revalidation; no-store forbids storing.
  • Age as now - Date: ignores Age from upstream caches and request delay, so an object held by a CDN is cached twice as long.
  • Vary ignored in the key: gzip bodies go to clients that did not ask for gzip. Vary: Cookie or User-Agent: near-zero hit ratio in shared caches.
  • Shared cache reusing Authorization responses without public, s-maxage or must-revalidate: leaks (§3.5).
  • stale-if-error is not a retry policy: it licenses stale answers on 500, 502, 503, 504 only; retry with backoff.

Variants and alternatives

Cache-Control: immutable (RFC 8246)fresh content is never revalidated; for fingerprinted assets.
Content-addressed URLshash in the path, long max-age; invalidate by renaming.
Purge APIs, surrogate keyswhen expiry is too slow and invalidation must be exact (Cache invalidation).
SSE, long pollingwhen freshness must be seconds.

In production

Feed readers and crawlers (URL frontier, robots.txt), CDNs and reverse proxies (Reverse proxy, CDN and edge), browsers and mobile stacks (OkHttp's disk cache), package mirrors, API SDKs, map tile servers (Map tile pyramid); partial reads reuse the same validators (Range requests).

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
http-cache-semanticsRustkornelski/rusty-http-cache-semanticscargo add http-cache-semantics————CachePolicy: storable, fresh, revalidation headers, 304 handling; shared flag; 10 % heuristic
http-cache-semanticsJSkornelski/http-cache-semanticsnpm install http-cache-semantics————the original of the Rust port
OkHttpKotlin/Javasquare/okhttpimplementation("com.squareup.okhttp3:okhttp:5.5.0")————client disk Cache; CacheStrategy decides freshness and conditional requests
CacheControlPythonpsf/cachecontrolpip install CacheControl————requests adapter; heuristics opt-in
Varnish CacheCvarnishcache/varnish-cachebrew install varnish————grace and keep (stale serving, revalidation); request coalescing
nginxCnginx/nginxbrew install nginx————proxy_cache_revalidate (default off), proxy_cache_use_stale, proxy_cache_lock; X-Accel-Expires beats Cache-Control
SquidC++squid-cache/squidbrew install squid————refresh_pattern min/percent/max heuristics
Apache Traffic ServerC++apache/trafficserverbrew install trafficserver————heuristic_lm_factor default 0.10

Sources

  • Fielding, Nottingham, Reschke, RFC 9111: HTTP Caching, 2022: §3, §3.2, §3.5, §4.1, §4.2.1-4.2.4, §4.3, §4.4, §5.2. rfc
  • Fielding, Nottingham, Reschke, RFC 9110: HTTP Semantics, 2022: §9.2.3, §13.1.2-13.1.3, §15.1, §15.4.5. rfc
  • Nottingham, RFC 5861: HTTP Cache-Control Extensions for Stale Content, 2010: §3, §4. rfc · McManus, RFC 8246: HTTP Immutable Responses, 2017. rfc
  • Mozilla, HTTP caching, MDN Web Docs. docs
  • nginx, ngx_http_proxy_module. docs · Varnish, Grace mode and keep. docs · Squid, refresh_pattern. docs · Apache Traffic Server, records.yaml. docs
  • Square, OkHttp CacheStrategy.kt. code · Python Software Foundation, CacheControl heuristics.py. code

Used by

CDN and edgeUsers far from your servers see slow responses, and traffic spikes or attacks hit the origin directly. This guide answers whether and how to put a CDN or edge compute in front of a service.
certificate revocation · plannedCRLs, OCSP, OCSP stapling, short-lived certificates and CRLite-style filters for revoked certificates
code splitting lazy loading · plannedSplit bundles at dynamic imports, load chunks on demand, preload critical paths

Systems (23):

Alternatives and related

Reverse proxyA service runs on several servers but clients must see one address. Something has to accept their connections, pick a healthy server per request, forward it and relay the answer, without hiding failures or holding whole bodies in memory.Cache invalidationA service keeps copies of data on many cache servers or edge nodes. When the source changes, some copies keep serving the old value, and nobody can find all copies quickly or tell a late refill from a fresh one.LRU and CLOCKA cache or page pool that is full must choose which entry to evict, and concurrent access must not serialize on a lock for every hit.HTTP range requestsA client needs only part of a large remote file (the tail of an archive, a column of a table, the next minutes of a video) or must resume a broken download. Fetching the whole file wastes time and bandwidth.HTTP/1.1 message parsingA program reads HTTP/1.1 from a TCP connection (server, proxy or crawler); bytes arrive in arbitrary pieces, and it must find where each message ends in exactly the way every other hop does.Robots exclusion protocolOperators of automated clients (search indexers, feed aggregators, web archivers, price monitors, link checkers) must decide, before each request, whether a site's owner permits their program to fetch that URL.
url frontier · plannedPrioritised per-host queues with politeness delays for crawling
Exponential backoff with jitterClients of a remote service that fails or throttles must retry in a way that does not hammer it in synchronized waves while it recovers: timeouts, 429/503 errors, reconnect loops.
map tile pyramid · plannedZ/x/y tile pyramids, quadkeys and overviews for multi-resolution maps and imagery