DEV Community

Mean for APIKumo

Posted on

ETags and Conditional Requests: Stop Sending the Same API Response Twice

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 }
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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:

  • private for anything user-specific so shared proxies don't cache it.
  • no-cache when you want clients to always revalidate with the ETag (it does not mean "don't cache").
  • max-age=0, must-revalidate for data that's sensitive to staleness but still benefits from ETag revalidation.
  • public, max-age=3600 only 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)