API Caching with ETag and Cache-Control: How Conditional Requests Cut Your Payloads

Learn how the Cache-Control header and ETag validation turn repeat API calls into 304 responses, prevent lost updates with If-Match, and cut payload size.

Ashley Goolam

Ashley Goolam

31 August 2026

API Caching with ETag and Cache-Control: How Conditional Requests Cut Your Payloads

Apidog for Enterprise

On-Premises Deploy

SSO & RBAC

SOC 2 Compliant

Explore Apidog Enterprise

Your API probably sends the same JSON thousands of times a day. A client asks for GET /v1/products/42, gets 18 KB back, asks again five minutes later, and gets the same 18 KB. Nothing changed. You paid for the bandwidth, the serialization, and the database read anyway.

HTTP already solved this problem. The Cache-Control header tells clients how long a response stays fresh. The ETag header gives them a fingerprint to check whether it changed. Together they turn repeat requests into 304 Not Modified responses with empty bodies, and they can protect your writes from lost updates as a bonus. The same ideas power client-side patterns too; if you’ve read our guide to caching API responses in React, this is the server side of that story.

This guide walks through the three layers of HTTP caching, shows the 304 round trip step by step, untangles no-cache vs no-store, and finishes with working Express code. You’ll also see how to verify all of it in Apidog by sending conditional headers and asserting on the 304 yourself.

button

The three layers of HTTP caching

HTTP caching for APIs breaks into three separate decisions. Teams get into trouble when they blur them together.

Layer 1: Freshness. How long can a client reuse a response without asking you at all? That’s Cache-Control: max-age=60. For 60 seconds, the client serves the cached copy locally. Zero network traffic. This is the cheapest cache hit possible and also the riskiest, because the client can’t detect a change until the timer expires.

Layer 2: Validation. Once the response goes stale, the client doesn’t have to re-download it. It asks “has this changed?” by sending the fingerprint you gave it earlier. If the resource is unchanged, you answer with 304 Not Modified and no body. ETag with If-None-Match is the precise version of this; Last-Modified with If-Modified-Since is the older, timestamp-based version with one-second granularity.

Layer 3: Invalidation. When data changes, how do stale copies die? Private client caches expire on their own via max-age. Shared caches and CDNs need explicit purges, short TTLs, or directives like stale-while-revalidate that bound staleness.

Freshness saves the most, validation catches everything freshness misses, and invalidation keeps both honest. Most APIs need all three.

How a 304 Not Modified round trip works

Here’s the full cycle for a product endpoint, step by step.

First request. The client has nothing cached:

GET /v1/products/42 HTTP/1.1
Host: api.example.com

First response. You return the body plus caching metadata:

HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
Content-Length: 18432

The client stores the body and the ETag. For the next 60 seconds it doesn’t contact you at all.

Second request, after 60 seconds. The copy is stale, so the client revalidates:

GET /v1/products/42 HTTP/1.1
Host: api.example.com
If-None-Match: "33a64df551425fcc55e4d42a148795d9f2"

Second response, unchanged resource. Your server compares the incoming ETag against the current one. They match, so:

HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"

No body. Instead of 18 KB, the response is a few hundred bytes of headers. The client marks its cached copy fresh for another 60 seconds and serves it. If the product had changed, you’d return a normal 200 with the new body and a new ETag. We covered the status code itself in more depth in our 304 Not Modified explainer; the short version is that a 304 is a cache instruction, not an error.

The economics are simple. A conditional GET still costs a round trip plus whatever work computes the current ETag. What it eliminates is payload transfer and client-side re-parsing. For large list endpoints polled by mobile clients, this routinely cuts API egress by 60 to 90 percent.

Cache-Control directives that matter for APIs

Cache-Control has more than a dozen directives. For JSON APIs, five carry most of the weight.

no-store vs no-cache. This is the most common caching bug in production APIs, and it runs in both directions. no-store means “never write this to any cache.” Use it for genuinely sensitive payloads: tokens, banking data, PII you must not persist. no-cache means almost the opposite of what it sounds like: caches MAY store the response, but they must revalidate with the origin before every reuse. Paired with an ETag, no-cache gives you 304 savings on every request while guaranteeing clients never show stale data. Teams that slap no-store on everything “to be safe” are disabling conditional requests entirely and paying full payload cost on every call.

private. Marks the response as cacheable by the end user’s client only, never by shared caches or CDNs. Any response that varies per user, which is most authenticated API traffic, should carry private. Without it, a misconfigured proxy can serve one user’s account data to another.

max-age. Freshness lifetime in seconds. For APIs, think small: 30 to 300 seconds covers most read endpoints. You’re not trying to eliminate requests for a day; you’re trying to absorb bursts and polling loops.

stale-while-revalidate. The pragmatic middle ground. Cache-Control: max-age=60, stale-while-revalidate=300 tells caches: serve the stale copy for up to 5 extra minutes, but refresh it in the background. Users get instant responses; your origin gets updated shortly after. CDNs like Cloudflare and Fastly support it, as do browsers.

A sensible default for an authenticated read endpoint looks like this:

Cache-Control: private, max-age=60, stale-while-revalidate=120
ETag: "9f8b2c41aa73e0d5"

The full behavioral spec lives in RFC 9111, which replaced RFC 7234 as the definitive HTTP caching document. When a CDN behaves in a way that surprises you, that RFC is where the answer lives.

Strong vs weak ETags

An ETag comes in two flavors, and the W/ prefix separates them.

A strong ETag (ETag: "33a64df551425fcc") promises byte-for-byte equality. Two responses with the same strong ETag are identical, which makes strong ETags safe for byte-range requests and required for concurrency control with If-Match.

A weak ETag (ETag: W/"33a64df551425fcc") promises semantic equivalence. The bytes may differ, maybe field ordering changed or a timestamp field ticked, but the meaning is the same, so a cache can keep its copy.

Where this bites you: compression middleware. Nginx and some frameworks rewrite strong ETags to weak ones when they gzip a response on the fly, because the compressed bytes no longer match the original. If your concurrency checks mysteriously fail behind a proxy, look for a W/ prefix that wasn’t there when your app server sent the response.

Default to strong ETags computed on the uncompressed body. Use weak ones only when you knowingly serve variant representations of the same data.

Generating ETags: body hash vs version column

Two strategies dominate, and the right one depends on where the cost sits.

Hash of the response body. Serialize the response, hash it (MD5 or SHA-1 is fine here; this is a fingerprint, not a security boundary), and quote it. It’s accurate by construction and needs no schema changes. The catch: you build the full response on every request, including the 304s. You save bandwidth but not compute or database load.

Version column or updated_at. Derive the ETag from data you can fetch cheaply: ETag: "42-v17" from the row’s version counter, or a hash of updated_at. Now a conditional request costs one indexed lookup instead of full serialization. The catch: the version must bump on every change that affects the response, including changes in joined tables. Miss one and you’ll serve stale 304s, which is the worst caching bug because it’s invisible.

Start with body hashing. It’s correct by default. Move hot endpoints to version-based ETags when profiling shows serialization cost matters.

ETags for optimistic concurrency: If-Match and 412

The same fingerprint that saves bandwidth on reads prevents lost updates on writes.

The lost update problem: two admins load product 42 at the same time. Admin A changes the price and saves. Admin B fixes a typo and saves 30 seconds later, overwriting A’s price change with the stale price B loaded. Nobody sees an error. The data is silently wrong.

The fix is to make every update conditional on the version the client last saw:

PUT /v1/products/42 HTTP/1.1
If-Match: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json

The server compares If-Match against the resource’s current ETag. Match: apply the update, return 200 with a new ETag. No match, someone else got there first: reject with 412 Precondition Failed and don’t touch the data. The client then re-fetches, reapplies its change on the fresh version, and retries. Strict APIs go further and return 428 Precondition Required on any PUT that omits If-Match, making the safety check mandatory.

This costs you almost nothing to add once ETags exist, and it converts a silent data corruption bug into an explicit, retryable HTTP status.

What CDNs and proxies do with these headers

Shared caches sit between your origin and your clients, and they read the same headers by their own rules.

Express example: returning an ETag and handling If-None-Match

Express sets weak ETags on its own, but manual handling gives you strong ETags plus the 412 write path:

import crypto from "node:crypto";
import express from "express";

const app = express();
app.use(express.json());

function etagFor(payload) {
  const hash = crypto.createHash("sha1")
    .update(JSON.stringify(payload))
    .digest("hex");
  return `"${hash}"`;
}

app.get("/v1/products/:id", async (req, res) => {
  const product = await db.products.find(req.params.id);
  const etag = etagFor(product);

  res.set("Cache-Control", "private, max-age=60, stale-while-revalidate=120");
  res.set("ETag", etag);

  if (req.get("If-None-Match") === etag) {
    return res.status(304).end();   // fingerprint matches: no body
  }
  res.json(product);
});

app.put("/v1/products/:id", async (req, res) => {
  const product = await db.products.find(req.params.id);
  const currentEtag = etagFor(product);
  const ifMatch = req.get("If-Match");

  if (!ifMatch) {
    return res.status(428).json({ error: "If-Match header required" });
  }
  if (ifMatch !== currentEtag) {
    return res.status(412).json({ error: "Resource changed since you fetched it" });
  }

  const updated = await db.products.update(req.params.id, req.body);
  res.set("ETag", etagFor(updated));
  res.json(updated);
});

Note the 304 branch still sends Cache-Control and ETag headers. Per RFC 9111, a 304 updates the stored response’s metadata, so resend anything the client needs to keep its copy fresh.

Verifying caching behavior in Apidog

Code that looks right can still cache wrong once middleware and proxies get involved. Test at the HTTP level, not the code level.

In Apidog, the manual check takes about a minute:

  1. Send GET /v1/products/42 and open the response headers panel. Confirm ETag and Cache-Control are present and the ETag is quoted. Copy the ETag value.
  2. On the same request, add a header If-None-Match with the copied value and send again. You should get a 304 with an empty body. If you still get a 200, your validation layer isn’t comparing fingerprints.
  3. Change the record, resend, and confirm you’re back to 200 with a fresh ETag.

To keep this working after every deploy, wire the same flow into a test scenario. Chain two requests: the first extracts ETag from the response headers into a variable, the second sends it back as If-None-Match and asserts the status equals 304 and the body is empty. Add a third step for the write path: send a PUT with a deliberately stale If-Match value like "deadbeefcafe1234" and assert 412. Our guide to API assertions covers the assertion syntax for status codes and headers.

Run that scenario in CI and a middleware upgrade that silently strips your ETags becomes a failed pipeline instead of a bandwidth bill. Download Apidog for free and build the scenario against your own endpoints; it takes longer to read about than to click together.

FAQ

What’s the difference between no-cache and no-store?

no-store forbids caching completely: nothing is written to disk or memory, so every request downloads the full response. no-cache allows storing but forces revalidation before each reuse, so paired with an ETag it still yields 304 responses and payload savings. Use no-store for sensitive data only. Using it everywhere is the single most expensive Cache-Control mistake an API team can make.

Do ETags work with POST?

Mostly no, and by design. ETags describe the state of a resource at a URL, and POST usually creates something new rather than reading stable state. Caches don’t cache POST responses in practice. The conditional headers that matter for writes are If-Match on PUT, PATCH, and DELETE, where the ETag guards against lost updates. If you’re tempted to cache POST responses, that’s usually a sign the operation should be a GET.

Does a 304 response make my API faster?

It makes transfers smaller, which is not the same thing. The server still receives the request, runs auth, and computes the current ETag, so origin CPU savings depend on how cheaply you derive that fingerprint. The wins show up in bandwidth, mobile battery, and time-to-render on slow networks. Measure before and after; our API performance testing guide shows how to benchmark latency and throughput so you can prove the difference instead of guessing.

Should I use ETag or Last-Modified?

Send both when you can. ETag is more precise: it catches sub-second changes and content-level differences that a timestamp misses, and If-None-Match takes priority over If-Modified-Since when both arrive. Last-Modified remains useful as a fallback for older clients and as a heuristic some caches use to estimate freshness. If you only ship one, ship ETag.

Explore more

REST API Naming Conventions: A Practical Style Guide

REST API Naming Conventions: A Practical Style Guide

Master REST API naming conventions with 10 concrete rules: plural nouns, kebab-case paths, JSON casing, versioning, and IDs. Do and don't examples included.

31 August 2026

How to Test OAuth 2.0 APIs in Apidog (Authorization Code, Client Credentials, and Token Refresh)

How to Test OAuth 2.0 APIs in Apidog (Authorization Code, Client Credentials, and Token Refresh)

Learn how to test OAuth 2.0 APIs in Apidog: authorization code flow with PKCE, client credentials, automatic token refresh, and 401/403 failure-path tests.

31 August 2026

Cursor-Based Pagination vs Offset Pagination: Which One Should Your API Use?

Cursor-Based Pagination vs Offset Pagination: Which One Should Your API Use?

Cursor-based pagination vs offset pagination compared: page drift, deep-offset cost, keyset SQL, Stripe and Slack examples, and how to test both in Apidog.

31 August 2026

Practice API Design-first in Apidog

Discover an easier way to build and use APIs

API Caching with ETag and Cache-Control: How Conditional Requests Cut Your Payloads