Bug Description
When revalidating a stale entry the interceptor builds the conditional request with
'if-modified-since': new Date(result.cachedAt).toUTCString()
(lib/interceptor/cache.js:354, background path :310) — i.e. the local clock at cache insertion
time, not the origin's stored Last-Modified value. RFC 9110 §13.1.3 is explicit that caches
should generate If-Modified-Since from the stored response's Last-Modified field. Two concrete
failure modes:
- Origins doing exact-match IMS comparison never return 304. nginx's default is
if_modified_since exact — since cachedAt never equals the resource's Last-Modified, every
Last-Modified-only revalidation (no ETag) against a default nginx gets a full 200. The entire
304 bandwidth saving is silently lost for that (very common) origin class.
- Clock skew: if the client clock runs ahead of the origin, a resource modified between
Last-Modified and (client-time) cachedAt can be masked — origin compares its mtime against a
too-late IMS date and answers 304, so undici serves stale data indefinitely.
Additionally the header is sent even when the stored response never had a Last-Modified at all
(the same expression), which is harmless but meaningless to most origins.
Reproducible By
Server that logs if-modified-since; respond 200 max-age=1, last-modified: <fixed past date>;
request twice with >1s between through a cache-composed dispatcher. Observed: IMS = insertion
wall-clock time, not the fixed Last-Modified (undici 8.7.0, current main, Node 22).
Expected Behavior
Use result.headers['last-modified'] when present; fall back to the stored Date (or omit IMS
and rely on If-None-Match) otherwise. The stored headers are already available on the GetResult
at both call sites, so this is a ~2-line change per site plus tests.
Found during an agent-assisted HTTP-caching review for @jeswr; every claim reproduced on undici 8.6.0 (repo) and 8.7.0 (npm) on Node 22.23.1. Fix PR to follow.
Bug Description
When revalidating a stale entry the interceptor builds the conditional request with
(lib/interceptor/cache.js:354, background path :310) — i.e. the local clock at cache insertion
time, not the origin's stored
Last-Modifiedvalue. RFC 9110 §13.1.3 is explicit that cachesshould generate If-Modified-Since from the stored response's Last-Modified field. Two concrete
failure modes:
if_modified_since exact— since cachedAt never equals the resource's Last-Modified, everyLast-Modified-only revalidation (no ETag) against a default nginx gets a full 200. The entire
304 bandwidth saving is silently lost for that (very common) origin class.
Last-Modified and (client-time) cachedAt can be masked — origin compares its mtime against a
too-late IMS date and answers 304, so undici serves stale data indefinitely.
Additionally the header is sent even when the stored response never had a Last-Modified at all
(the same expression), which is harmless but meaningless to most origins.
Reproducible By
Server that logs
if-modified-since; respond200 max-age=1, last-modified: <fixed past date>;request twice with >1s between through a cache-composed dispatcher. Observed: IMS = insertion
wall-clock time, not the fixed Last-Modified (undici 8.7.0, current main, Node 22).
Expected Behavior
Use
result.headers['last-modified']when present; fall back to the storedDate(or omit IMSand rely on If-None-Match) otherwise. The stored headers are already available on the GetResult
at both call sites, so this is a ~2-line change per site plus tests.
Found during an agent-assisted HTTP-caching review for @jeswr; every claim reproduced on undici 8.6.0 (repo) and 8.7.0 (npm) on Node 22.23.1. Fix PR to follow.