Skip to content

fix(image-optimizer): respect updated upstream Cache-Control when reusing cached image#94143

Open
itoudium wants to merge 1 commit into
vercel:canaryfrom
itoudium:fix/image-optimizer-respect-updated-cache-control
Open

fix(image-optimizer): respect updated upstream Cache-Control when reusing cached image#94143
itoudium wants to merge 1 commit into
vercel:canaryfrom
itoudium:fix/image-optimizer-respect-updated-cache-control

Conversation

@itoudium
Copy link
Copy Markdown

What?

When an optimized image is revalidated and the upstream source bytes are
unchanged (same upstream etag), Next.js reuses the previously optimized buffer
to skip re-encoding. This PR makes that path return the maxAge derived from
the freshly re-fetched upstream Cache-Control header, instead of the
revalidate value persisted in the previous cache entry.

Why?

The optimized-image cache TTL was effectively frozen at the value computed on
the very first fetch. Because the reuse branch returned the previous entry's
revalidate, changing the upstream/CDN Cache-Control max-age was never
reflected as long as the source image bytes stayed identical — each
revalidation kept re-emitting the original TTL.

This reuse optimization was introduced in #67257; the buffer reuse itself is
correct (the optimized output is identical), but carrying over the old TTL was
an unintended side effect.

Reproduction

  1. images: { minimumCacheTTL: 0 }, a remote/internal image whose upstream
    responds with Cache-Control: max-age=2.
  2. next build && next start, request /_next/image?url=...&w=64&q=75
    response Cache-Control reports max-age=2.
  3. Let the cache entry go stale (> 2s).
  4. Change the upstream to e.g. Cache-Control: max-age=600 without changing
    the image bytes
    .
  5. Request again → the response still reports max-age=2; the new value is
    never picked up.

(Originally observed under output: 'standalone', but this is not specific to
standalone. The bug is in Next.js's built-in Image Optimization cache, which runs
on any self-hosted server (next start and output: 'standalone' alike). It does
not affect managed/minimal-mode deployments, where image optimization is handled
by the platform rather than this code path.)

How?

In imageOptimizer(), the reuse branch returned
opts?.previousCacheEntry?.cacheControl?.revalidate || maxAge. The freshly
computed maxAge (already Math.max(minimumCacheTTL, getMaxAge(upstream.cacheControl)))
is now returned directly, so the buffer reuse / CPU optimization from #67257 is
preserved while the cache TTL tracks the current upstream header.

Added a unit test (test/unit/image-optimizer/image-optimizer-max-age.test.ts)
covering both the updated-max-age case and the minimumCacheTTL clamp. The
test fails before the change (Received: 31536000) and passes after.

…sing cached image

When an optimized image becomes stale and is revalidated, the upstream
source is re-fetched. If the source bytes are unchanged (same upstream
etag), Next.js reuses the previously optimized buffer to skip re-encoding
(vercel#67257). However it also reused the previous cache entry's revalidate
value as the maxAge, which froze the cache TTL at the value computed on
the very first fetch.

As a result, changing the upstream/CDN Cache-Control max-age was never
reflected as long as the source image bytes stayed the same.

Return the maxAge derived from the freshly re-fetched upstream
Cache-Control header instead. The buffer reuse (CPU optimization) is
preserved; only the TTL now tracks the current upstream header.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant