Your API Is Sending the Same Bytes Over and Over
Most APIs re-serialize and re-send identical payloads thousands of times a day. The client already has the data, the data hasn't changed, and yet every request burns a full round trip plus bandwidth plus database time. HTTP solved this in 1999 with conditional requests — and most APIs still don't use them.
This post shows you how to add ETag and Cache-Control to a REST API so clients can ask "has anything changed?" and get a tiny 304 Not Modified instead of the whole body.
The Two Headers That Do the Work
Cache-Control tells the client how long a response stays fresh. ETag is a fingerprint of the response body that lets the client revalidate once it goes stale. Together they let a client skip the network entirely while fresh, then revalidate cheaply afterward.
A typical exchange looks like this:
GET /api/products/42 HTTP/1.1
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "a1b2c3d4"
Content-Type: application/json
{ "id": 42, "name": "Wireless Mouse", "price": 1999 }
On the next request, the client sends the fingerprint back:
GET /api/products/42 HTTP/1.1
If-None-Match: "a1b2c3d4"
HTTP/1.1 304 Not Modified
ETag: "a1b2c3d4"
No body. No serialization. Often no database read. Just a few dozen bytes confirming "you already have it."
Implementing It in Express
Here's a small Node/Express handler that generates a strong ETag from the response content and short-circuits on a match.
import express from "express";
import crypto from "node:crypto";
const app = express();
function makeETag(payload) {
const json = JSON.stringify(payload);
const hash = crypto.createHash("sha1").update(json).digest("base64url");
return `"${hash}"`;
}
app.get("/api/products/:id", async (req, res) => {
const product = await db.getProduct(req.params.id);
if (!product) return res.sendStatus(404);
const etag = makeETag(product);
res.set("Cache-Control", "private, max-age=60");
res.set("ETag", etag);
// Client already has this exact version → revalidate cheaply
if (req.headers["if-none-match"] === etag) {
return res.status(304).end();
}
res.json(product);
});
The key detail: you still load the object to compute its ETag here, but you skip JSON serialization and the network payload. To skip the database read too, store a version column or updated_at timestamp and build the ETag from that instead of the full body:
const meta = await db.getProductVersion(req.params.id); // cheap row
const etag = `"${meta.id}-${meta.version}"`;
if (req.headers["if-none-match"] === etag) return res.status(304).end();
const product = await db.getProduct(req.params.id); // only when changed
res.set("ETag", etag).json(product);
Don't Forget Writes
ETags aren't just for reads. The same fingerprint powers optimistic concurrency on updates via If-Match, which stops two clients from silently overwriting each other:
app.put("/api/products/:id", async (req, res) => {
const current = await db.getProductVersion(req.params.id);
const etag = `"${current.id}-${current.version}"`;
if (req.headers["if-match"] && req.headers["if-match"] !== etag) {
return res.status(412).send("Precondition Failed"); // stale write
}
const updated = await db.updateProduct(req.params.id, req.body);
res.set("ETag", `"${updated.id}-${updated.version}"`).json(updated);
});
A 412 here means "someone changed this since you last read it" — the client refetches and retries instead of clobbering data.
Picking Cache-Control Directives
A few rules that cover most APIs:
-
privatefor anything user-specific so shared proxies don't cache it. -
no-cachewhen you want clients to always revalidate with the ETag (it does not mean "don't cache"). -
max-age=0, must-revalidatefor data that's sensitive to staleness but still benefits from ETag revalidation. -
public, max-age=3600only for genuinely shared, non-personalized resources.
Closing
Conditional requests are one of the highest-leverage, lowest-effort wins in API performance: a few headers turn repeated full responses into near-empty 304s and protect your writes from race conditions for free. The hard part is usually testing it — eyeballing If-None-Match and If-Match behavior across endpoints by hand is tedious. That's where APIKumo helps: you can define requests in a shared workspace, capture an ETag from one call, and feed it straight into the next request's If-None-Match or If-Match header with pre/post processors — so verifying that your caching and concurrency logic actually returns 304 and 412 takes seconds, not a debugging session.
Top comments (0)