The caching logic in ClientCacheFilters.kt follows
RFC 9111 “HTTP Caching”.
The problem#
HTTP clients typically make a network round-trip for every request, even when the server has declared the response to be cacheable. That wastes bandwidth, adds latency, and generates unnecessary load on the origin server. Browsers avoid this with a caching layer that:
- serves fresh responses straight from a local cache without touching the network;
- revalidates stale responses against the origin using entity validators;
- turns a
304 Not Modifiedresponse back into the cached representation.
ClientCacheFilters brings that behaviour to any http4k HttpHandler (typically a client) as a
single, stateless Filter:
val client = ClientCacheFilters().then(JavaHttpClient())
The filter reads the storage/expiry instructions from the server’s response headers
(Cache-Control, Expires, Age, ETag, Last-Modified, Vary) and from the request’s own
Cache-Control directives, and automatically fills the cache as traffic flows through it. The
storage layer is pluggable via ClientCacheStorage; the default
InMemoryClientCacheStorage is a bounded, thread-safe, LRU cache.
How requests are decided#
For every GET/HEAD request the filter computes:
- Freshness lifetime - how long the response may be considered fresh
(
max-age/s-maxage,Expires, or heuristic). - Current age -
Ageheader + time spent in this cache. - Whether it can serve stale - subject to
stale-while-revalidate,stale-if-error,max-stale, and the request’sonly-if-cached/max-age/min-freshdirectives.
If the cached entry is fresh it is served with no network call. If it is stale (or the request
demands revalidation) the filter sends a conditional request and, on a 304, returns the cached
body with updated headers.
Scenarios#
Fresh response with Cache-Control: max-age#
The most common case: the server tells the cache how long a response may be reused. Subsequent requests inside the window are served from memory and never reach the network.
Server:
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
Content-Length: 5
hello
val hits = AtomicInteger()
val server = HttpHandler {
hits.incrementAndGet()
Response(OK).body("hello").header("Cache-Control", "public, max-age=3600")
}
val client = ClientCacheFilters(timeSource = { now }).then(server)
client(Request(GET, "http://host/hello")) // 1 network call, body "hello"
// advance the clock by 10 minutes
client(Request(GET, "http://host/hello")) // served from cache, still 1 network call
The age of the served copy is reported in the Age response header.
Cache-Control: no-store - never cache#
A response marked no-store must not be kept anywhere. Every request hits the network:
HTTP/1.1 200 OK
Cache-Control: no-store
secret
client(Request(GET, "http://host/secret"))
client(Request(GET, "http://host/secret")) // hits the network again
Cache-Control: no-cache on the response - store but always revalidate#
no-cache does not mean “don’t cache” - it means “never serve from cache without checking with
the server first”. The body is cached so a 304 can be served from it, but every request goes
over the wire:
HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "abc"
versioned-content
client(Request(GET, "http://host/versioned-content")) // 200, cached
client(Request(GET, "http://host/versioned-content")) // conditional request -> 304 -> cached body
Expires freshness fallback#
If there is no Cache-Control the cache falls back to the deprecated Expires date:
HTTP/1.1 200 OK
Date: Tue, 15 Nov 2026 08:12:31 GMT
Expires: Tue, 15 Nov 2026 18:12:31 GMT
hello
The response is considered fresh until Expires.
Heuristic caching from Last-Modified#
When there is no explicit freshness information at all, the cache applies the RFC 9111 heuristic:
10% of the time between Last-Modified and Date:
HTTP/1.1 200 OK
Date: Tue, 15 Nov 2026 08:12:31 GMT
Last-Modified: Tue, 15 Nov 2026 00:00:00 GMT
hello
Here the heuristic freshness lifetime is (08:12 - 00:00) * 0.1 ≈ 49 minutes. Disable with
ClientCacheFilters(heuristicCaching = false).
Revalidation with ETag and If-None-Match#
When a cached response becomes stale and carries an ETag, the filter revalidates by echoing it
in If-None-Match. A 304 Not Modified is translated back into the cached representation:
HTTP/1.1 304 Not Modified
ETag: "abc"
Cache-Control: public, max-age=600
The original status (200), body, and Content-Type are retained; the fresh headers from the
304 (and the recomputed Content-Length) are merged in, and the entry is re-stored so it is
fresh for another window. The caller only ever sees a 200.
var hits = 0
val server = HttpHandler { req ->
hits++
if (req.header("If-None-Match") == "\"abc\"") {
Response(NOT_MODIFIED).header("ETag", "\"abc\"").header("Cache-Control", "public, max-age=600")
} else {
Response(OK).body("hello").header("ETag", "\"abc\"").header("Cache-Control", "public, max-age=5")
}
}
val client = ClientCacheFilters(timeSource = { now }).then(server)
client(Request(GET, "http://host/resource")) // 200, body "hello", hits = 1
// advance past the 5s max-age
client(Request(GET, "http://host/resource")) // 304 -> cached body "hello", hits = 2
client(Request(GET, "http://host/resource")) // fresh again, hits = 2
Revalidation with Last-Modified and If-Modified-Since#
If there is no ETag, the filter uses Last-Modified and sends If-Modified-Since instead:
HTTP/1.1 200 OK
Last-Modified: Tue, 15 Nov 2026 08:00:00 GMT
Cache-Control: public, max-age=5
hello
GET /resource HTTP/1.1
If-Modified-Since: Tue, 15 Nov 2026 08:00:00 GMT
Age header accelerates staleness#
A response may already be old by the time it reaches us. Following RFC 9111 §4.2.3, the Age
header is added to the time spent in our cache, so a max-age window can expire early:
HTTP/1.1 200 OK
Cache-Control: public, max-age=100
Age: 90
The remaining freshness is only 10 seconds from the moment it was stored.
Vary - cache per request-header combination#
Responses that change based on request headers use Vary. The filter stores the request header
values it was generated from and only reuses the entry when the current request matches:
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
Vary: Accept-Language
français
client(Request(GET, "http://host/page").header("Accept-Language", "fr")) // cached for fr
client(Request(GET, "http://host/page").header("Accept-Language", "fr")) // cache hit
client(Request(GET, "http://host/page").header("Accept-Language", "en")) // miss -> network
A Vary: * response is never reused (its value always fails to match, RFC 9111 §4.1), and a
single variant is kept per method+URI (the most recently stored write wins).
Conditional requests from the client#
Requests can also carry Cache-Control directives:
Request ... Cache-Control: no-cache- force revalidation even if the cached response is fresh.Request ... Cache-Control: no-store- bypass the cache entirely.Request ... Cache-Control: only-if-cached- never touch the network; serve whatever is cached (even stale), or return504 Gateway Timeoutif there is nothing.Request ... Cache-Control: max-age=0- revalidate (this is what browsers send on reload).Request ... Cache-Control: max-stale=60- accept a response up to 60 seconds past its freshness lifetime, andmax-stalealone accepts any staleness.Request ... Cache-Control: min-fresh=30- only serve if at least 30 seconds of freshness remain.
client(Request(GET, "http://host/page").header("Cache-Control", "only-if-cached"))
// -> cached copy if present, otherwise 504 Gateway Timeout (no network call)
immutableresponses (an extension directive, from the browser ecosystem) are exempt from the reload (max-age=0) revalidation while they remain fresh.
stale-while-revalidate and stale-if-error#
Two companion directives soften staleness for resilience and speed:
stale-while-revalidate=N- serve the stale copy for up toNseconds past its freshness lifetime without waiting on the network.stale-if-error=N- keep the stale copy usable, and if a revalidation attempt fails with a5xx, serve the stale copy instead of propagating the error.
HTTP/1.1 200 OK
Cache-Control: public, max-age=10, stale-while-revalidate=300
hello
// within max-age -> served fresh, no network
// up to 300s past max-age -> still served without a network call
// beyond that -> revalidated over the network
Both are extension directives from RFC 5861,
referenced by RFC 9111 §4.2.4, and are honoured only when the stored response does not forbid it
(no-cache, must-revalidate, proxy-revalidate).
Write methods invalidate the cache#
Non-safe methods (POST, PUT, DELETE, PATCH, …) are passed straight through and
invalidate any cached entry for the target URI, plus any Location/Content-Location target
(RFC 9111 §4.4), so a subsequent GET sees fresh data:
client(Request(GET, "http://host/thing")) // cached
client(Request(POST, "http://host/thing")) // invalidates the entry
client(Request(GET, "http://host/thing")) // re-fetched, not served from cache
Authorization requests#
A response to a request carrying an Authorization header is only cached if the response also
has public, s-maxage, or must-revalidate (RFC 9111 §3.5):
HTTP/1.1 200 OK
Cache-Control: public, max-age=300
account-balance
HTTP/1.1 200 OK
Cache-Control: private, max-age=300 <- NOT cached for authenticated requests
max-age=300 <- also NOT cached for authenticated requests
Which responses are cached#
By default only GET responses (and, from the cache, HEAD requests) with a heuristically
cacheable status code are stored - RFC 9111 §3 / §4.2.2: 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501. Other statuses pass through untouched. The set is overridable via
ClientCacheFilters(cacheableStatusCodes = ...).
Observing decisions#
A response replayed from the cache carries an up-to-date Age header, which is the simplest way
to distinguish a cache hit from a fresh network response. For richer telemetry, wrap the client
with the standard http4k event/tracing filters (e.g. ClientFilters.ReportHttpTransaction) - a
served-from-cache response will produce no outgoing transaction event.
Limitations#
- A single cached variant is kept per method+URI (last-writer-wins across
Varyvariants). - Responses containing
Set-Cookieare still cached, as a browser would. - When serving inside a
stale-while-revalidatewindow the filter does not trigger a background refresh (it is a synchronous filter); the entry is revalidated on the first request that falls outside the window.
