# Caching & ETags

> Conditional requests cost no credits — how to store ETags, revalidate, and pick a cache layer.

Source: https://pokemontcgapi.com/docs/caching-etags

Every cacheable response carries a strong `ETag` and a `Cache-Control`. Sending the ETag back on your next request turns a full response into a `304 Not Modified`, which touches no database row and **costs no credits**.

That is a deliberate incentive, not an oversight. A client that resynchronises often is the client we want, and charging for the privilege would push you towards polling less and being more stale.

## The round trip

`revalidate.sh`

```bash
# First call — full body, note the ETag
curl -si "https://api.pokemontcgapi.com/v1/sets/sv3" -H "X-Api-Key: $PTCG_API_KEY" | grep -i etag
# etag: "o6DkBk61AqCBy1I5NtiqFLhjA8a"

# Second call — unchanged, so 304 and no body
curl -si "https://api.pokemontcgapi.com/v1/sets/sv3" \
  -H "X-Api-Key: $PTCG_API_KEY" \
  -H 'If-None-Match: "o6DkBk61AqCBy1I5NtiqFLhjA8a"'
# HTTP/2 304
```

The `304` still carries `ETag` and `Cache-Control`, so the next validation starts from a fresh baseline rather than from nothing.

> **That ETag is a real one**
>
> It was read from `/v1/sets/sv3` on 2026-08-27 and will be stale the next time that set changes — which is the point of a validator. Copy the mechanism, not the string.

## A caching client

`cached-get.ts`

```ts
const store = new Map<string, { etag: string; body: unknown }>();

export async function cachedGet(url: string, key: string): Promise<unknown> {
  const hit = store.get(url);

  const res = await fetch(url, {
    headers: {
      "X-Api-Key": key,
      ...(hit ? { "If-None-Match": hit.etag } : {}),
    },
  });

  // 304 means what we already hold is still current. No body is sent,
  // so reading one here would hang.
  if (res.status === 304 && hit) return hit.body;
  if (!res.ok) throw new Error((await res.json()).error.code);

  const body = await res.json();
  const etag = res.headers.get("ETag");
  if (etag) store.set(url, { etag, body });

  return body;
}
```

## Cache policy by family

Read from the live API on 2026-08-27.

| Family | `Cache-Control` | Reasoning |
| --- | --- | --- |
| Reference lists — `/v1/reference` | `public, max-age=86400` | These change when a set is released, not between requests. |
| Catalogue — cards, sets, artists, and prices via `include=prices` | `public, max-age=60, s-maxage=300, stale-while-revalidate=600` | Fresh quickly at the edge, tolerant of staleness while it refreshes behind the reader. |

> **`Vary` never includes your API key**
>
> It looks prudent and it destroys shared caching: every customer would get a private copy of a response that is byte-identical for everyone. Catalogue responses do not depend on who is asking, so they are cached once for all of you.

## What the ETag is computed from

A SHA-256 over the canonical serialised body, truncated to 27 base64url characters. It is a strong validator: identical ETag means byte-identical body.

- The ETag changes when the **response** changes, which includes changes caused by your own parameters — `select`, `include`, `lang` and `limit` all produce different bodies and therefore different ETags.
- Key by full URL, not by resource id. Two `select` lists on the same card are two cache entries.
- Weak comparison (`W/"…"`) is accepted on the way in, so an intermediary that weakened your validator does not break revalidation.
- `If-None-Match: *` always matches, which is occasionally handy in tests.

## Where to put a cache

### In your process

An LRU keyed by URL is enough for read-heavy apps and takes ten lines, as above. Bound it — the catalogue is bigger than your heap.

### At the edge

Catalogue responses are `public`, so a CDN or shared cache in front of your service works without configuration. `stale-while-revalidate=600` means users get an instant answer while the refresh happens behind them.

### In your database

If you mirror the catalogue, do not revalidate row by row. Every card carries `updated_at` and a monotonic `row_version`; store the highest `updated_at` you have imported and re-read only the sets whose cards moved past it. Sets change on a release schedule, so most nights that is a handful of requests rather than a full walk.

## Compression

Responses are compressed with Brotli or gzip according to your `Accept-Encoding`. On 2026-08-27, `GET /v1/cards?limit=50` was **40,568 bytes** uncompressed, **2,133** with `Accept-Encoding: br` and **2,300** with gzip — a 95% reduction either way, because JSON with repeating keys is close to the best case for a dictionary coder.

Most HTTP clients negotiate this already. If yours does not, sending the header is a one-line change with a twenty-fold payoff on bandwidth.

> **The cheapest two changes**
>
> Send `If-None-Match`, and ask only for the `select` fields you actually render. Neither changes what your users see, and together they usually cut credit consumption by more than half.
