Implementing ETag-based Caching Revalidation for TanStack Start
Serve your blog faster with fewer server resources by using HTTP caching and ETags to avoid repeated rendering and unnecessary downloads.
Ben Houston • • 8 min read
A blog can serve the same article thousands of times between edits. With HTTP caching, browsers and CDNs can reuse that page while it is fresh, then ask whether it has changed before downloading it again. If you check that question before server-side rendering, an unchanged page also avoids the work of running its loader and rendering React.
The payoff is faster delivery with less server work and less data transferred. A fresh browser cache hit needs no network request, and a fresh CDN hit serves the page without contacting your application server. When a cached page needs validation, an unchanged article only needs a small response with no HTML body. That validation still takes a network round trip, but checking the ETag before rendering saves server CPU and bandwidth. Under repeated traffic, these savings leave more capacity for other requests and can reduce usage-based hosting costs.
You need two headers with different jobs: Cache-Control tells a cache when it may reuse a response, and ETag identifies the version it has. This tutorial combines them in a TanStack Start article route, using the same approach as this Markdown blog.
Choose a Version That Covers the Page#
For a blog whose content ships with its application, a Git commit is a useful content version. It changes when you update posts, templates, or other committed inputs to the page. Make that commit available to the running server as BUILD_COMMIT, through your deployment's environment variables or Docker build arguments.
The version must change whenever the page's meaningful content changes. If you publish through a CMS independently of deployments, include its content revision too. Rebuilding the same commit with different page-affecting configuration also needs a new version. A deployment-based validator is deliberately broad: changing one post invalidates the validators for every post.
This approach assumes public article pages whose content is shared across visitors. Personalized HTML needs a cache policy and validators that account for those differences; it should not use this shared public cache unchanged.
Combine the content version with the full request URL so different articles and query strings get different validators:
const etag = `W/"${createHash('sha256') .update(JSON.stringify([buildCommit, request.url])) .digest('hex')}"`
The W/ prefix makes this a weak ETag. It represents equivalent content without promising identical response bytes. That suits server-rendered HTML where serialization timestamps can change even though the article has not. Hashing the commit and URL does not hash the actual HTML, so it would not justify a strong ETag.
Set the Freshness Policy#
For a blog that can tolerate a short delay before edits become visible, use:
Cache-Control: public, max-age=60, must-revalidate
publicpermits shared caches, including CDNs, to store the response.max-age=60allows a cached response to be reused while its age is under 60 seconds.must-revalidateprevents reuse of a stale response without successful validation.
Once the response becomes stale, a cache can send its saved ETag in If-None-Match. A match produces 304 Not Modified, allowing the cache to reuse its stored body. A mismatch produces 200 OK with fresh HTML and its validator. Revalidation happens when another request needs the page, not on a background timer.
Choose the lifetime according to how quickly edits need to appear. A different deployment version changes the ETag, but it cannot invalidate a browser's still-fresh copy immediately. If every reuse must be validated, Cache-Control: public, no-cache permits storage while requiring validation; no-cache does not mean no-store.
Implement the Conditional Check#
Keep the check in a server module. The following helper uses the same logic as this blog and accepts an article-existence function so you can connect your own content source:
// src/server/articleCache.ts import { createHash } from 'node:crypto' const CACHE_CONTROL = 'public, max-age=60, must-revalidate' type ArticleCacheOptions = { enabled: boolean buildCommit: string articleExists: (slug: string) => Promise<boolean> } export const createArticleCache = ({ enabled, buildCommit, articleExists }: ArticleCacheOptions) => async (request: Request, slug: string) => { if (!enabled || !buildCommit || !['GET', 'HEAD'].includes(request.method)) return if (!(await articleExists(slug))) return const etag = `W/"${createHash('sha256') .update(JSON.stringify([buildCommit, request.url])) .digest('hex')}"` const validator = request.headers.get('If-None-Match') const notModified = Boolean( validator && (validator.trim() === '*' || Array.from(validator.matchAll(/(?:W\/)?"[^"\r\n]*"/g)).some( ([tag]) => tag.replace(/^W\//, '') === etag.replace(/^W\//, ''), )), ) return { headers: { ETag: etag, 'Cache-Control': CACHE_CONTROL }, notModified } }
There are three details worth getting right. First, check that the article exists before matching a validator. If-None-Match: * matches any existing representation; it must not turn a missing article into a 304.
Second, If-None-Match can contain multiple tags. Match the quoted tags rather than splitting on commas, because commas can occur inside a tag. Use weak comparison: W/"abc" and "abc" match for this condition. These rules come from HTTP's definition of If-None-Match.
Third, limit this helper to GET and HEAD. Conditional requests for writes have different semantics.
Create one helper instance using your content lookup. For example, with the blog's getBlogEntries function:
import { getBlogEntries } from '../utilities/blog/getBlogEntries' export const getArticleCacheResult = createArticleCache({ enabled: process.env.ARTICLE_CACHE_ENABLED === 'true', buildCommit: process.env.BUILD_COMMIT ?? '', articleExists: async (slug) => (await getBlogEntries()).filter((entry) => entry.id === slug).length === 1, })
This example uses an explicit environment switch; this blog also enables caching automatically for production builds targeting its production environment. Set ARTICLE_CACHE_ENABLED=true and a nonempty BUILD_COMMIT to enable the example. Leave it disabled during ordinary development, when edits can happen without a version change.
Use a cheap content lookup here. For deployed Markdown, you can load the article index once per server instance, as this blog does in production. A matching request then needs no HTML rendering or per-request file hashing.
Return 304 Before Rendering#
TanStack Start server routes can handle an HTTP request before the page loader and rendering run. Add this server block to your article route, keeping its existing loader, component, and metadata:
import { setResponseHeader } from '@tanstack/react-start/server' import { createFileRoute } from '@tanstack/react-router' import { getArticleCacheResult } from '~/server/articleCache' export const Route = createFileRoute('/blog/$slug')({ // Keep your existing loader, component, and head options here. server: { handlers: { GET: async ({ request, params, next }) => { const result = await getArticleCacheResult(request, params.slug) if (result?.notModified) { return new Response(null, { status: 304, headers: result.headers, }) } if (result) { setResponseHeader('ETag', result.headers.ETag) setResponseHeader('Cache-Control', result.headers['Cache-Control']) } return next() }, }, }, })
A matching request returns an empty 304 with the validator and cache policy. Otherwise, next() continues to the usual page rendering. Requests without a validator also take that path, so the first HTML response carries the headers a cache needs for later reuse.
Unknown slugs fall through to the route's normal redirect or 404 handling. HEAD uses Start's GET-handler fallback. This handles article document requests; server-function calls made during client navigation are a separate request path.
If you use a CDN, make sure it is eligible to cache article HTML and respects the origin's freshness policy. Cloudflare does not cache HTML by default, so it needs a Cache Rule enabling that behavior. Preserve query strings in the cache key when they affect the page.
Verify the Request Flow#
Test against your origin server with caching enabled, using an existing article URL. curl does not maintain a browser-style response cache, so these commands let you control the conditional request directly:
ARTICLE_URL='http://localhost:8080/blog/your-article' curl -sS -D - -o /dev/null "$ARTICLE_URL"
Expect 200 OK, an ETag beginning with W/, and Cache-Control: public, max-age=60, must-revalidate. Copy the complete ETag value, including its quotes, into the next command:
curl -i -H 'If-None-Match: W/"paste-the-hash-here"' "$ARTICLE_URL"
Expect 304 Not Modified, the same ETag and cache policy, and no response body. You do not need to wait 60 seconds: an explicit conditional request tests validation immediately.
Now send an outdated tag:
curl -i -H 'If-None-Match: W/"old-version"' "$ARTICLE_URL"
Expect 200 OK with the article HTML. Finally, restart or redeploy with a different BUILD_COMMIT and resend the previously valid tag. That should also return 200, this time with a different ETag. This last check confirms that publishing a new version invalidates the old validator.