Caching & ETags
Conditional requests cost no credits — how to store ETags, revalidate, and pick a cache layer.
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
# 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 304The 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
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,langandlimitall produce different bodies and therefore different ETags. - Key by full URL, not by resource id. Two
selectlists 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.