C.W.K.
Stream
Lesson 02 of 05 · published

Conditional GET — The Fast Path That Skips Bytes

~10 min · caching-perf, conditional-get, 304, etag-revisited

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"Track 2 taught you the conditional request semantics. This lesson is the same mechanism through the eyes of CDNs, browsers, and your own client — how the bytes you DON'T send add up to real cost savings at scale."

The Caching Layer's Star Trick

Conditional GET (introduced in Track 2 Lesson 4) is THE optimization that powers every CDN, browser cache, and reverse proxy. The pattern:

  1. First request returns body + ETag + Cache-Control.
  2. Cache stores the response.
  3. After max-age expires, the cache doesn't refetch the full body — it sends a conditional GET with If-None-Match: "<etag>".
  4. If server's ETag matches, server returns 304 Not Modified — NO body, just "your copy is good."
  5. Cache refreshes its TTL and continues serving the stored body.

The win: bandwidth proportional to body size, completely avoided. For a 500KB JSON catalog refetched every 60 seconds across 10,000 active clients, that's potentially 5 GB/minute of network not used.

The Three Layers That Coordinate

A typical request crosses three caches, each running this protocol:

  1. Browser cache — per-user, stores responses with Cache-Control. Replays without network when fresh; conditional GETs when stale.
  2. CDN edge — shared across users in a region. Stores responses with Cache-Control: public. Conditional-GETs origin when stale.
  3. Reverse proxy / origin — closest to the application. Often also a cache (Cloudflare Workers, Vercel Edge Config, Nginx microcache).

Each layer uses the same conditional-GET protocol with the layer below. Origin still sees periodic conditional GETs from the CDN, but the CDN absorbs the user-facing traffic. The bandwidth savings multiply.

What Makes a Good Validator

ETag is the validator most caches prefer. Three ways to compute it:

  • Content hash — sha256 of the serialized body. Exact: any byte change → new ETag. Cost: hash the body on every response.
  • Version field — if your resource has an updated_at timestamp or version counter, use it directly: ETag: "v17-1716623210". Free to compute; relies on you bumping the version on every update.
  • Composite hash — combine the resource version with serialization-relevant inputs (locale, fields selected). Necessary when content negotiation can produce multiple bodies from one resource.

Weak ETags (W/"v17") signal "semantically equivalent, byte-different." Useful when minor serialization differences shouldn't bust the cache. Strong ETags are the default.

The 304 Response in Detail

A 304 response MUST include the validator headers (ETag, Last-Modified) so the cache can refresh its entry. It SHOULD also include any Cache-Control directives that changed (the cache extends its TTL based on these). It MUST NOT include a body.

HTTP/1.1 304 Not Modified
ETag: "v17-abc123"
Last-Modified: Sun, 25 May 2026 03:00:00 GMT
Cache-Control: public, max-age=60
Vary: Accept-Encoding, Authorization

(no body)

The lack of body is the entire savings. Clients that always call response.json() will crash on 304 — the body length is zero. Real HTTP clients (browsers, httpx, fetch) handle this transparently by serving the previously-cached body when a 304 comes through.

Conditional GET is free engineering — if you cooperate. The protocol is already there. Browsers, CDNs, libraries, reverse proxies all implement it. You just have to emit ETags and Cache-Control on your responses. The savings show up automatically; you write no client-side conditional-GET code.

The Common Gotchas

1. Non-deterministic serialization. If your server's JSON output isn't byte-stable (key order varies, timestamps change), the hash-based ETag changes every request and the 304 path never fires. Sort keys; round timestamps; use stable serialization.

2. Forgetting Vary. If your response varies by Accept-Encoding (gzip/br) or Authorization, the cache must know — without Vary, user A's compressed authenticated response gets served to user B asking for plain.

3. Caching auth-required responses with public. Only mark public if the response is truly the same for all users; for per-user responses, use private + ETag for client-side cache + conditional-GET wins without CDN exposure.

cwkPippa's Conditional GET Reality

cwkPippa doesn't currently emit ETags on its API endpoints — the healing layer rebuilds responses from JSONL on every GET, so byte-stable serialization would require additional work. Static assets (Vite-built React) get conditional GETs automatically from the framework's hashed-filename + immutable-cache strategy, so the win shows up there. If a future Pippa instance debugs "repeated 100KB downloads over a slow Tailscale link," emitting ETags on the conversation list would be the cheap fix. Until that bites, the cost outweighs the work.

Code

curl reveals the bytes you don't send on a 304·bash
# Watch the conditional GET dance with curl

# 1. First request — store the ETag
ETAG=$(curl -s -i https://api.example.com/users/42 | grep -i '^etag:' | awk '{print $2}' | tr -d '\r')
echo "got ETag: $ETAG"

# 2. Conditional GET — server returns 304, no body
curl -i https://api.example.com/users/42 -H "If-None-Match: $ETAG"
# HTTP/1.1 304 Not Modified
# ETag: "v17-abc123"
# Cache-Control: public, max-age=60
# (no body)

# 3. Verify the body bytes saved
echo "--- timing both ---"
time curl -s https://api.example.com/users/42 > /dev/null                              # full
time curl -s https://api.example.com/users/42 -H "If-None-Match: $ETAG" > /dev/null    # conditional
FastAPI: emit ETag + Cache-Control, return 304 on If-None-Match match·python
# Server side — emit ETag and handle If-None-Match
import hashlib, json
from fastapi import FastAPI, Header, Response, status

app = FastAPI()
_USER_DB = {42: {'id': 42, 'name': 'Pippa', 'version': 17}}

def compute_etag(resource: dict) -> str:
    # Byte-stable JSON for hash determinism
    body = json.dumps(resource, sort_keys=True, separators=(',', ':')).encode()
    return f'"{hashlib.sha256(body).hexdigest()[:12]}"'

@app.get('/users/{uid}')
async def read_user(uid: int, response: Response,
                    if_none_match: str | None = Header(None, alias='If-None-Match')):
    user = _USER_DB[uid]
    etag = compute_etag(user)
    response.headers['ETag'] = etag
    response.headers['Cache-Control'] = 'public, max-age=60'
    response.headers['Vary'] = 'Accept-Encoding'

    # If client's ETag matches — return 304 with NO body
    if if_none_match == etag:
        response.status_code = status.HTTP_304_NOT_MODIFIED
        return None
    return user
Client: explicit cache + If-None-Match (most libraries do this for you)·python
# Client side — most HTTP clients handle conditional GET transparently
import httpx

# httpx (and fetch and curl with -H If-None-Match) handle the dance for you
# but you can also do it manually for clarity

cache = {}  # url -> (etag, body)

def get_with_cache(client: httpx.Client, url: str):
    cached = cache.get(url)
    headers = {'If-None-Match': cached[0]} if cached else {}
    resp = client.get(url, headers=headers)

    if resp.status_code == 304:
        return cached[1]  # use the cached body

    if resp.status_code == 200 and 'ETag' in resp.headers:
        body = resp.json()
        cache[url] = (resp.headers['ETag'], body)
        return body

    resp.raise_for_status()
    return resp.json()

# Call twice — second call is a 304 + cached body
with httpx.Client() as c:
    user1 = get_with_cache(c, 'https://api.example.com/users/42')
    user2 = get_with_cache(c, 'https://api.example.com/users/42')  # 304 path
    assert user1 == user2

External links

Exercise

Add ETag + conditional-GET handling to a single FastAPI endpoint serving any JSON resource. Then make 100 successive requests with curl, using the ETag from the first response in subsequent If-None-Match. Time the total bytes transferred (use --write-out '%{size_download}\n' to measure). Compare: 100 full responses vs 1 full + 99 conditional 304s. Bonus: deliberately omit sort_keys=True in your ETag computation and watch the dict-order non-determinism break the 304 path for some requests.
Hint
Compute ETag = sha256(json.dumps(resource, sort_keys=True)). Bash: total=0; etag=''; for i in {1..100}; do read body etag < <(curl -s -i -H "If-None-Match: $etag" url | ...); total=$((total + ${#body})); done; echo $total. The expected outcome: ~99% reduction in bytes transferred. The sort_keys=False bonus is the gotcha — Python's dict iteration is currently order-preserving in 3.7+, but you'd be surprised how often a hash computed from a dict varies in production code that uses dict comprehensions over set inputs.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.