Add ETag Revalidation to Read Endpoints
Derive stable entity tags, answer If-None-Match with 304, and keep validators correct when compression or CDNs transform the bytes.
Task
Let polling clients and CDNs revalidate cached JSON cheaply instead of downloading an identical body on every request.
When to use this
A dashboard polls a read endpoint every five seconds. The payload rarely changes, yet each poll transfers the full 80 KB body and burns mobile data for no reason.
Prerequisites
- Node.js 22 with Express 4.18 or newer
- A read endpoint that returns a deterministic JSON body
- curl for inspecting response headers
Steps
1. Choose validator strength deliberately: strong only when the bytes are identical, weak when compression or a CDN may transform them.
import { createHash } from 'node:crypto';
const app = express();
app.set('etag', false); // we emit explicit weak tags below
function weakTag(value) {
const hash = createHash('sha256').update(JSON.stringify(value)).digest('base64url').slice(0, 27);
return `W/"${hash}"`;
}
Expected output: The service emits explicit W/"..." validators instead of strong ones that compression would invalidate.
2. Set ETag and Cache-Control before writing the body, then let req.fresh decide between 200 and 304.
app.get('/api/orders', async (req, res) => {
const rows = await listOrders();
res.set('Cache-Control', 'no-cache');
res.set('ETag', weakTag(rows));
if (req.fresh) return res.status(304).end();
res.json(rows);
});
Expected output: A request with a matching If-None-Match receives 304 with no body; others receive 200 and a fresh ETag.
3. Make the representation stable so the tag does not change when nothing meaningful changed.
const canonical = rows
.map((r) => ({ id: r.id, status: r.status, total: r.total }))
.sort((a, b) => a.id - b.id);
res.json(canonical); // no updatedAt, no random request fields
Expected output: Repeated requests for unchanged data produce the same ETag across processes and restarts.
4. Revalidate with curl and confirm the byte saving.
ETAG=$(curl -sI http://localhost:3000/api/orders | awk -F': ' 'tolower($1)=="etag"{print $2}' | tr -d '\r'); curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -H "If-None-Match: $ETAG" http://localhost:3000/api/ordersExpected output: 304 0, and the response still carries ETag, Cache-Control, and Vary headers.
Troubleshooting
Problem
The ETag changes on every request
Solution
The body includes timestamps, random IDs, or unsorted rows. Compute the tag over a stable projection with deterministic ordering.
Problem
Clients report 200 every time despite sending If-None-Match
Solution
A proxy may be stripping the header or rewriting the ETag. Check the edge configuration and confirm the origin sees If-None-Match.
Limitations
- The server must build the full representation before it can hash it, so ETags do not help CPU-heavy endpoints.
- ETags only cover one URL; changing query parameters must change the response and therefore the tag.
Revalidation is cheaper than it looks
An ETag is an opaque validator for a specific representation. When a client sends If-None-Match with the tag it holds, the server can answer 304 Not Modified with headers only. That turns an 80 KB JSON download into a few hundred bytes and lets mobile clients keep their cached copy while still seeing changes immediately. Unlike a long max-age, revalidation does not delay updates.
Strong versus weak
A strong validator promises byte-for-byte equality. That promise breaks when gzip, Brotli, or a CDN rewrites the payload, because the selected representation differs. Weak validators (W/"...") only promise semantic equivalence and are what If-None-Match compares anyway. If you compress dynamically, prefer weak tags or compute the tag after encoding. For immutable, content-addressed assets, strong tags are correct and useful for range requests.
Build it from canonical content
The common failure is a tag that changes every request because the body contains updatedAt or rows in unstable order. Serialize a projection with sorted keys and stable row order, and exclude request-scoped fields such as trace IDs. If the tag is derived from a database version column instead of the payload, make sure every write bumps that version, otherwise clients will cache stale data behind a valid 304.
app.get('/api/orders', async (req, res) => {
const rows = await listOrdersCanonical();
res.set('Cache-Control', 'no-cache');
res.set('ETag', weakTag(rows));
if (req.fresh) return res.status(304).end();
res.json(rows);
});
Keep headers on the 304
A 304 must repeat ETag, Cache-Control, and Vary so the client can update its stored metadata. It must not include a body; some frameworks will send one if you only change the status code, which confuses clients and wastes the saving. Verify with curl -o /dev/null -w '%{size_download}'.
Check it after deploys
Changing the serialization format changes every tag at once, which is fine but produces a burst of 200s. Include a schema or serializer version in the tag input if you want to reason about it, and watch the 304 ratio after each deploy to confirm caches are revalidating rather than refetching.