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
- Storable? Not with
no-store; not withprivatein a shared cache; not if the request hadAuthorizationin a shared cache withoutpublic,s-maxageormust-revalidate(§3, §3.5). Needs an understood method (in practice GET, HEAD) and explicit freshness (public,max-age,Expires,s-maxage, orprivatein a private cache) or a heuristically cacheable status: 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501 (RFC 9110 §15.1). - Vary (§4.1): reuse only if every request header named by
Varymatches the stored request's;Vary: *never matches. - Lifetime, first match (§4.2.1):
s-maxage(shared),max-age,Expires - Date, else heuristic: a fraction (typically 10 %) ofDate - Last-Modified(§4.2.2), else 0. Invalid values count as stale. - Age (§4.2.3):
apparent = max(0, response_time - Date);corrected = Age + (response_time - request_time);current_age = max(apparent, corrected) + (now - response_time). - Fresh iff
lifetime > current_ageand nono-cache: serve, no request. - Stale but allowed:
stale-while-revalidate=N(RFC 5861 §3) serves for N more seconds while refreshing in the background, unlessmust-revalidate, orproxy-revalidate/s-maxagein a shared cache (§4.2.4). - Revalidate (§4.3.1): send
If-None-Match(MUST, when an ETag exists) andIf-Modified-Since(SHOULD). No validator: plain refetch. - Handle the answer (§4.3.3-4.3.4):
304updates the stored headers (§3.2) and restarts age;200replaces the entry;5xxmay serve stale ifstale-if-errorallows (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
304ends at the header section (RFC 9110 §15.4.5): headers only, order of hundreds of bytes. - Real caches add key hashing,
Varylookup 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 typicalCache-Controlvalues, 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
304carries 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
Varyaxes (avoidVary: Cookie) — fewer variants per URL (RFC 9111 §4.1) — shared caches.
- Poll no faster than
- Naive → standard → state of the art:
- Fetch every time · full body per poll.
- Honour freshness, revalidate with validators · no request while fresh, headers-only
304after. - 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
Expiresmeans expired. - Cap
delta-secondsat 2^31 on overflow (§1.2.2); the code does. - Store
request_timeandresponse_timewith the entry; use a monotonic clock fornow - response_time. - On
304, update stored headers exceptContent-Length(§3.2). Send the storedETagverbatim,W/included:If-None-Matchcompares weakly (RFC 9110 §13.1.2). - Heuristics differ: OkHttp applies 10 % only to URLs without a query; Traffic Server defaults
heuristic_lm_factorto 0.10; Varnish falls back todefault_ttl(2 min); CacheControl's opt-inLastModifiedheuristic caps at one day.
Pitfalls
no-cacheread as "do not store": it forces revalidation;no-storeforbids storing.- Age as
now - Date: ignoresAgefrom upstream caches and request delay, so an object held by a CDN is cached twice as long. Varyignored in the key: gzip bodies go to clients that did not ask for gzip.Vary: CookieorUser-Agent: near-zero hit ratio in shared caches.- Shared cache reusing
Authorizationresponses withoutpublic,s-maxageormust-revalidate: leaks (§3.5). stale-if-erroris 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.max-age; invalidate by renaming.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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| http-cache-semantics | Rust | kornelski/rusty-http-cache-semantics | cargo add http-cache-semantics | — | — | — | — | CachePolicy: storable, fresh, revalidation headers, 304 handling; shared flag; 10 % heuristic |
| http-cache-semantics | JS | kornelski/http-cache-semantics | npm install http-cache-semantics | — | — | — | — | the original of the Rust port |
| OkHttp | Kotlin/Java | square/okhttp | implementation("com.squareup.okhttp3:okhttp:5.5.0") | — | — | — | — | client disk Cache; CacheStrategy decides freshness and conditional requests |
| CacheControl | Python | psf/cachecontrol | pip install CacheControl | — | — | — | — | requests adapter; heuristics opt-in |
| Varnish Cache | C | varnishcache/varnish-cache | brew install varnish | — | — | — | — | grace and keep (stale serving, revalidation); request coalescing |
| nginx | C | nginx/nginx | brew install nginx | — | — | — | — | proxy_cache_revalidate (default off), proxy_cache_use_stale, proxy_cache_lock; X-Accel-Expires beats Cache-Control |
| Squid | C++ | squid-cache/squid | brew install squid | — | — | — | — | refresh_pattern min/percent/max heuristics |
| Apache Traffic Server | C++ | apache/trafficserver | brew 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