jsonhttpapicachingperformance

ETag & Conditional Requests: Caching JSON APIs Efficiently

·8 min read·APIs

Don't re-send what hasn't changed

A client re-fetches a JSON resource — refreshing a page, polling for updates — and nine times out of ten, nothing has changed since the last fetch. Re-sending the full JSON body every time wastes bandwidth and server work for no reason. ETags and conditional requests are the HTTP mechanism for skipping that waste: the server tags each response with a fingerprint, the client remembers it, and on the next request the client asks "has this changed since fingerprint X?" — if not, the server replies with an empty 304 Not Modified instead of resending the JSON body at all.

The ETag: a fingerprint for a response

An ETag is an opaque string the server attaches to a response, meant to change if and only if the underlying resource changes:

http
GET /api/orders/ord_42
http
200 OK
ETag: "a1b2c3d4e5f6"
Content-Type: application/json

{ "id": "ord_42", "status": "shipped", "total": 149.97 }

The client stores this ETag alongside the response. On the *next* request for the same resource, it sends the ETag back in an If-None-Match header:

http
GET /api/orders/ord_42
If-None-Match: "a1b2c3d4e5f6"

If the order hasn't changed, the server responds with no body at all:

http
304 Not Modified
ETag: "a1b2c3d4e5f6"

The client already has the data from the last successful fetch and simply keeps using it — the round trip still happens (there's still a request), but the expensive part (serializing and transmitting the JSON body) is skipped entirely.

Where the ETag value actually comes from

An ETag can be computed however the server wants, as long as it changes when the resource does. Two common approaches:

javascript
// Content hash — robust, but requires computing a hash on every request
import { createHash } from "crypto";
const etag = '"' + createHash("sha256").update(JSON.stringify(order)).digest("hex").slice(0, 16) + '"';
javascript
// Version or updated_at column — cheap, if your data already tracks it
const etag = '"' + order.id + "-" + order.updatedAt.getTime() + '"';

The second pattern is far more common in practice: if your database row already has an updated_at timestamp or a version integer, deriving the ETag from it costs nothing extra, versus hashing the full serialized body on every single request.

Weak vs strong validators

An ETag prefixed with W/ is a weak validator — it asserts the response is *semantically equivalent*, not byte-for-byte identical (whitespace or key order might differ). A bare-quoted ETag is a strong validator, asserting byte-for-byte identity:

http
ETag: "a1b2c3d4"      # strong — must be byte-identical
ETag: W/"a1b2c3d4"    # weak — semantically equivalent is enough

For JSON APIs, weak ETags are usually the right default — you rarely care that whitespace or key order is byte-identical, only that the *data* hasn't meaningfully changed, and weak validation is cheaper to compute correctly (a version number rather than a full-content hash).

If-Match: preventing lost updates

If-None-Match is for reads (skip re-sending unchanged data). Its counterpart, If-Match, guards writes against a classic race condition: two clients load the same resource, both edit it, and whoever saves second silently overwrites the first person's changes with no error. If-Match makes the write conditional on the resource being unchanged since you last read it:

http
PUT /api/orders/ord_42
If-Match: "a1b2c3d4e5f6"
Content-Type: application/json

{ "status": "cancelled", "total": 149.97 }

If the current ETag no longer matches (someone else updated the order in the meantime), the server rejects the write with 412 Precondition Failed instead of silently applying a stale update over newer data:

http
412 Precondition Failed

This pattern — called optimistic concurrency control — is a far cheaper alternative to locking rows for the duration of an edit, and it's exactly the same idea as a database's optimistic-locking version column, expressed at the HTTP layer instead of the database layer.

Last-Modified: the simpler, coarser sibling

Before ETags, HTTP had (and still supports) Last-Modified/If-Modified-Since, based on a timestamp rather than an opaque fingerprint:

http
Last-Modified: Mon, 06 Jul 2026 09:15:00 GMT
http
GET /api/orders/ord_42
If-Modified-Since: Mon, 06 Jul 2026 09:15:00 GMT

It's simpler to implement (many frameworks derive it automatically from a file's or row's modification time) but coarser — it only has one-second resolution, so two genuinely different updates within the same second are indistinguishable. ETags are preferred whenever precision matters or the "changed" definition isn't simply "the row's timestamp."

Implementing it in Express

javascript
app.get("/api/orders/:id", async (req, res) => {
  const order = await getOrder(req.params.id);
  const etag = 'W/"' + order.id + "-" + order.updatedAt.getTime() + '"';

  if (req.headers["if-none-match"] === etag) {
    return res.status(304).end(); // no body — client already has this
  }

  res.set("ETag", etag);
  res.json(order);
});

Most frameworks (Express with etag, Django REST Framework, ASP.NET) have built-in or easily-added middleware that automates the hashing and header comparison — you rarely need to hand-roll the comparison logic shown here in production.

Frequently asked questions

No — that's the entire point. A 304 has no body at all; the client is expected to reuse the response it already cached from the prior successful request with a matching ETag.

If-None-Match is used on reads (GET) to ask "only send me the full body if this has changed." If-Match is used on writes (PUT/PATCH/DELETE) to ask "only apply this change if the resource hasn't changed since I last read it" — protecting against overwriting someone else's concurrent update.

A version number or updated_at timestamp your database already tracks is cheaper and just as effective for most APIs. Reserve a full content hash for cases where you genuinely need to detect any byte-level difference regardless of whether your data model tracks versions.

Compare the ETag your client sent against what the server currently computes for that resource — paste both the cached and current JSON bodies into JSONKit's JSON Diff tool to see whether the underlying data actually changed, which usually reveals whether the bug is in your ETag computation or a genuine stale-cache issue.

Try JSONKit — JSON Developer Tools

Format, validate, convert and diff JSON. Instant, accurate and 100% private.