ETags are one of the cheapest performance wins available to a React app talking to a JSON API. The browser does most of the work, the server returns a body-less 304 when the data has not changed, and the user sees response times that drop by a large fraction without any visible cost.
This guide walks through the practical steps to wire ETags into a React application end to end, with the patterns that work and the gotchas to know about.

Photo by Brett Sayles on Pexels
Step 1: Make sure the server returns ETags
ETags are server-driven. The server attaches an ETag: "some-value" header to each response, and the browser stores it alongside the body. On subsequent requests, the browser sends If-None-Match: "some-value" and the server either returns 304 with no body (cache stays valid) or 200 with a new body and a new ETag.
The implementation on the server can be one of two patterns:
- Content-hash ETag. The server hashes the response body (SHA-1, MD5, anything stable). Useful for any response, but does not save the server-side work of generating the response.
-
Version-based ETag. The server uses a version or
updated_atfield from the underlying entity. Saves both the database read and the response generation, because revalidation can check the version alone.
Express, Next.js, and most modern Node.js frameworks support both patterns out of the box. Frameworks like Fastify and Hono make this even easier with first-class ETag middleware. Verify your server is actually sending ETags by inspecting the response headers in the browser dev tools network panel.
Step 2: Confirm the browser is sending If-None-Match on revalidation
If your Cache-Control headers are set to allow caching (e.g., Cache-Control: private, max-age=0, must-revalidate), the browser should automatically include If-None-Match on every subsequent request. Open the network panel, find your request, and check the request headers.
If the header is missing, the most likely cause is a Cache-Control: no-store directive somewhere in your response chain that is preventing the browser from storing the response at all. A no-store response cannot be revalidated, because there is nothing to revalidate against. Change to no-cache (the response is stored but must be revalidated on every use) if you want ETag-driven freshness.
Step 3: Pick a React data-fetching library that respects HTTP caching
Most modern React data-fetching libraries layer their own cache on top of the browser cache, and not all of them play nicely with HTTP-level caching. The two that handle this well:
-
TanStack Query (formerly React Query) honors HTTP caching by default if you use the
fetchAPI directly inside your query function. The library's own staleTime and cacheTime are layered on top. - SWR does the same. The library's own cache is keyed by URL, and the underlying fetch respects browser HTTP caching transparently.
The library-level cache is configured to be aggressive by default (stale data is served while a background revalidation fires). The browser-level cache adds another layer underneath, providing the 304-based optimization without any extra code.
import { useQuery } from '@tanstack/react-query'
function UserProfile({ userId }) {
const { data, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
staleTime: 30 * 1000,
})
if (isLoading) return <Skeleton />
return <ProfileCard user={data} />
}
The fetch call here automatically picks up the browser's cached response when revalidation produces a 304. No additional configuration needed.

Photo by fauxels on Pexels
Step 4: Set up Cache-Control to allow the browser to cache
The Cache-Control header decides whether the browser will store the response at all. For ETag-driven revalidation to work, the response must be storable. The directive that works for most user-specific API endpoints:
Cache-Control: private, max-age=0, must-revalidate
This says: store the response, but always revalidate before serving it. The revalidation uses the ETag. On 304, the cache is served. On 200, the cache is replaced. Both outcomes are correct.
For endpoints where stale-while-revalidate is appropriate, add it:
Cache-Control: private, max-age=0, stale-while-revalidate=60, must-revalidate
This serves the cached response immediately while firing a background revalidation, giving the user a fast load and converging to fresh within sixty seconds.
Step 5: Verify the round trip with the dev tools
A working ETag round trip shows up in the network panel as:
- First request: 200 OK, full response body,
ETagheader in response. - Subsequent requests within the cache window: 304 Not Modified, no response body,
If-None-Matchheader in request.
If you see 200 every time, the browser is not honoring the cache. Check Cache-Control on the response. If you see 304 but the response body in the panel is still the full body, that is the dev tools showing the cached body for clarity, not the actual network response. The actual network transfer is just the 304 headers.
A common gotcha: the browser's network panel has a "Disable cache" checkbox that, when on, prevents any cache from being used. Make sure it is off when testing.
"The 304 round trip is the single cheapest thing you can do to make a React app feel snappy. Almost no code changes; almost all the win comes from the server doing its part." - Dennis Traina, founder of 137Foundry
Step 6: Handle the invalidation case
When the underlying data changes, the server's next response will be 200 with a new ETag. The browser stores the new response and the new ETag. The next revalidation uses the new ETag, gets a 304, and the cycle continues.
There is one case where this falls down: if the underlying data changed but the user's open tab does not know about it. Until the user takes an action that triggers a refetch, the UI continues to show the previous response.
The pattern that solves this is to combine ETag revalidation with library-level staleness. TanStack Query and SWR will refetch on window focus and on network reconnect by default. These triggers, combined with the 304 round trip when nothing has changed, give you fast loads and reasonably fresh data without explicit work.
For real-time updates where the user must see changes immediately, the right pattern is server-pushed invalidation via WebSocket or Server-Sent Events. ETags are for the case where freshness within a few seconds of revalidation is good enough, which covers most dashboards and admin interfaces.
The architecture team at 137Foundry defaults to the ETag-plus-library-revalidation pattern for any React app with significant API traffic. It is the strategy with the best performance-per-line-of-code ratio.
Step 7: Layer in a small monitoring habit
The last piece is a monitoring habit that catches regressions when the ETag pipeline degrades. Add a small instrumented wrapper around your fetch calls that logs the response status, the elapsed time, and whether the response was a 304 or a 200. Aggregate these metrics in your observability tool of choice.
The metric to watch is the 304 rate over time. Healthy ETag-driven caching produces a 304 rate of 40% to 70% on typical user sessions. If the rate drops suddenly, something has changed about the cache directives or the ETag implementation. Catching this within a day saves the team from a slow degradation that nobody notices until the API costs start climbing.
For an app at modest scale, this monitoring is two lines of code wrapped around the fetch call. For an app at larger scale, an APM tool like Datadog or Sentry's performance monitoring handles it more cleanly. The discipline matters more than the tool.
For the deeper version of this guide, including the broader browser caching architecture and where the Cache API fits in, see the 137Foundry article on browser API caching. The web development service page covers some of the related architectural work as well.
Top comments (0)