Skip to content

Revalidate cached responses with their ETag - #113

Merged
MattCCC merged 4 commits into
masterfrom
etag-revalidation
Sep 27, 2026
Merged

MattCCC merged 4 commits into
masterfrom
etag-revalidation

Conversation

@MattCCC

@MattCCC MattCCC commented Sep 27, 2026

Copy link
Copy Markdown
Owner

This pull request adds ETag revalidation. When a cached response has an ETag, the requests that revalidate it ask the server whether it changed, with an If-None-Match header. If it didn't, the server answers 304 Not Modified without a body, and the cached response is reused instead of being downloaded and parsed again, which saves bandwidth and spares rerenders.

✨ Features & Improvements

  • ETag revalidation: GET and HEAD requests that use the cache send the ETag of their cached response in If-None-Match, e.g. background revalidations of stale data, refetchOnFocus, refetchOnReconnect, polling or requests with a cacheBuster. On 304 Not Modified, the cached response is reused and cached again, so it's fresh for another staleTime.
  • No rerenders: a reused response is the same object with the same data. It isn't parsed, transformed with select or passed to onResponse again, so e.g. useFetcher() doesn't rerender after a background revalidation that is answered with 304.
  • Works with cacheStore: a stale response restored after a page reload is revalidated with its ETag too.
  • Added the etag setting, which is true by default, so it can be disabled with etag: false.
  • Safe by default:
    • Requests with an If-None-Match header of their own are sent as they are, and handle 304 responses themselves.
    • Requests with a custom fetcher only send ETags with etag: true, as some fetchers, e.g. ones built on axios, treat 304 responses as errors.
    • mutate() removes the ETag of the response it changes, as it no longer matches the data, so that a revalidation can't keep local changes that the server doesn't have.
  • Documented in a new "ETag Revalidation" section of Cache Management, including the CORS headers that cross-origin servers need.

⚠️ Behavior Change

Revalidations of cached responses that have an ETag now send an If-None-Match header. In browsers, the ETag of a cross-origin response can only be read when the server exposes it with Access-Control-Expose-Headers, and such servers must also allow the If-None-Match header with Access-Control-Allow-Headers, otherwise the revalidation fails its CORS preflight. Setting etag: false restores the previous behavior.

🧪 Tests

  • Reusing the cached response on 304, background revalidation of stale data, revalidation while the cache is marked as fetching, no second select or onResponse, a new version of the response, and a response restored from a cache store.
  • HEAD requests, and no ETag for other methods, for responses without one, for requests without cache, with etag: false, with an own If-None-Match header, and with custom fetchers unless it's enabled.
  • mutate() dropping the ETag.
  • useFetcher() not rerendering after a focus revalidation answered with 304, unlike after one answered with a response without ETag.
  • Coverage stays at 100%.

📦 Bundle Size

The core bundles grow by about 0.2 KB (brotli). The size limits are raised to fit this and the other pull requests opened alongside it, so that they can be merged in any order.


Generated by Claude Code

When a cached response of a GET or HEAD request has an ETag, the requests that revalidate it, e.g. background revalidations, refetchOnFocus or requests with a cacheBuster, send the ETag in the If-None-Match header. If the server answers 304 Not Modified, the cached response is reused and cached again instead of being downloaded and parsed again, so it keeps its identity and e.g. useFetcher() doesn't rerender for it. It's enabled by default and can be disabled with etag: false. Requests with a custom fetcher only send ETags with etag: true, as fetchers may treat 304 responses as errors. Requests with an If-None-Match header of their own are sent as they are, and mutate() drops the ETag of the response it changes, as it no longer matches the data.
Core bundles 7.75 KB -> 8.75 KB, dist/react/index.mjs 10.75 KB -> 11.75 KB and dist/react/index.js 11.75 KB -> 12.75 KB. The limits fit this and the other PRs opened alongside it (abort signal fix, ETag revalidation, retry jitter and methods, onDownloadProgress, responseType, focusThrottleInterval, initialData and typed OpenAPI endpoints), which add about 1 KB together, so that they can be merged in any order.
@github-actions

Copy link
Copy Markdown

size-limit report 📦

Path Size
dist/browser/index.mjs 7.47 KB (+2.22% 🔺)
dist/browser/index.global.js 7.65 KB (+2.2% 🔺)
dist/node/index.js 7.54 KB (+1.97% 🔺)
dist/react/index.mjs 10.47 KB (+1.35% 🔺)
dist/react/index.js 11.4 KB (+1.65% 🔺)

@MattCCC
MattCCC merged commit bf5cb21 into master Sep 27, 2026
14 checks passed
@MattCCC
MattCCC deleted the etag-revalidation branch September 27, 2026 20:18
@MattCCC MattCCC mentioned this pull request Sep 27, 2026
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.

2 participants