Caching and ETags
Send If-None-Match and respect Cache-Control so you make far fewer requests.
Caching is per-route opt-in on the API: most public GETs mount the
cache() middleware (Cache-Control + ETag), live routes mount noStore,
and a route that mounts neither sets no cache header at all. Follow these two
rules and most of your polls come back as a cheap 304.
Rule 1: send If-None-Match
Cache the response body along with its ETag. On every subsequent request,
send If-None-Match: <etag>. The API responds with 304 Not Modified (no
body, tiny latency, no work on the server) whenever your cached copy is
still current.
Tags are weak (W/"...") and the match is an exact string comparison, so send
the header value back verbatim, W/ prefix and quotes included. A real exchange:
$ curl -si https://api.motoswap.org/points/rules | grep -iE '^(HTTP|cache-control|etag)'
HTTP/2 200
cache-control: public, max-age=300, s-maxage=300, stale-while-revalidate=900
etag: W/"/points/rules|g2|points-rules-v6.0-dbdd67e9ab749e53"
$ curl -si https://api.motoswap.org/points/rules \
-H 'If-None-Match: W/"/points/rules|g2|points-rules-v6.0-dbdd67e9ab749e53"' | head -1
HTTP/2 304Treat the tag as opaque. Its contents change between releases.
let cache: { etag?: string; body?: unknown } = {};
async function fetchWithEtag<T>(url: string): Promise<T> {
const r = await fetch(url, {
headers: cache.etag ? { "If-None-Match": cache.etag } : {},
});
if (r.status === 304) return cache.body as T;
const body = await r.json();
cache = { etag: r.headers.get("etag") ?? undefined, body };
return body;
}Rule 2: respect Cache-Control
Do not re-fetch before max-age elapses. The tiers:
| Tier | max-age | stale-while-revalidate | Used by |
|---|---|---|---|
HEARTBEAT | 1 s | 9 s | GET /chain/head |
BLOCK | 3 s | 9 s | Indexer-fresh routes (/tokens/..., /token/{addr}/price, /dex/pools, /portfolio/..., /rakeback/{addr}, /analytics/overview, /analytics/*/series, /stats/series) |
CRON | 30 s | 90 s | Cron-derived routes (/analytics/breakdown, /swap/candidates, /explore/creators, /points/leaderboard, /points/weeks, /points/leagues) |
STATIC | 300 s | 900 s | Config-stable routes (/config, /points/rules, /rakeback/stats, past-epoch /rakeback/{addr}/epoch/{epoch}) |
(CRON_FAST is mounted on GET /profiles. /rakeback/{addr}/proof is
STATIC, because a posted merkle root never changes, so its proofs don't either. The tag
folds the address with a roots watermark, so it only flips when a new tree
the wallet appears in is published.)
The full header is public, max-age=N, s-maxage=N, stale-while-revalidate=M.
GET /config carries the STATIC header but no ETag, so it never answers
304; cache it for its max-age. /health/live and /version are
private, no-store.
Browsers and CDNs do this for you. Bots and backends have to do it
themselves. From a browser on another origin, a plain GET works, but a fetch
where your script sets If-None-Match triggers a preflight, and the API's CORS
policy, which is set for the Motoswap app origin, blocks it. Conditional polling
with an explicit If-None-Match must run server-side, or rely on the browser's
own HTTP cache, which revalidates without a preflight.
How the version is derived
The ETag is built from the request path, the query params the route
recognises, and a version key. Unrecognised query params do not change the
tag, so a cache-busting ?z=123 gains nothing. The version keys:
blockVersion()
(indexer head block number + head hash). Used by any indexer-driven
route. A reorg changes the hash, so the ETag changes even when the block
number stays the same.
cronVersion([CronJob…])
max(last_run_at) over the named cron jobs. Used by routes that depend on
a cron output (e.g. refresh-rankings for /swap/candidates).
addrVersion(addr, userVersion)
The wallet's last_activity_block. Used by per-user routes
(/portfolio/{addr}, /rakeback/{addr}, etc.). Only refreshes when THAT
user transacts.
userVersion(addr, variant)
Subset of addrVersion; used when a per-user route depends on activity
plus a variant discriminator.
Per-user routes are safe under 304
/portfolio/{addr}, /rakeback/{addr}, /{addr}/meta, etc. key the ETag
on the address's last_activity_block. Rapid polling for a wallet's state
is cheap: the response is 304 until the wallet transacts.
/points/{addr} is the one exception: it keys on userPointsVersion (the
wallet's dropped-day + total + activity block), so its ETag also moves when
the daily drop lands, not only when the wallet transacts.
Cross-user isolation
The address is in the URL path, so:
- CDN cache keys are per-address by construction (no additional
Varyis needed). - One wallet's cached entry can never be served for another wallet.
Cursor pagination + ETag
The full URL (including ?cursor=…) is the cache key. A different cursor
gets a different ETag. Consecutive pages under a stable cursor cache
independently.
Writes are no-store
Every POST endpoint sets Cache-Control: private, no-store. Don't cache them.
Error / 4xx / 5xx responses
An error is never cached. If the tier says max-age=300 but the handler
returns 503, the response header is force-overridden to no-store in the
middleware and the ETag is dropped, so a transient failure won't sit in your
cache. The same holds for 400, 403, 404 and 429.
Example: a client-side fetch wrapper
export class CachedFetch {
private store = new Map<string, { etag: string; body: unknown; expires: number }>();
async get<T>(url: string): Promise<T> {
const now = Date.now();
const hit = this.store.get(url);
// Serve from cache if fresh - no request at all.
if (hit && hit.expires > now) return hit.body as T;
const r = await fetch(url, {
headers: hit?.etag ? { "If-None-Match": hit.etag } : {},
});
// Freshness window from Cache-Control (max-age).
const cc = r.headers.get("cache-control") ?? "";
const maxAge = /max-age=(\d+)/.exec(cc)?.[1];
const expires = now + (maxAge ? Number(maxAge) * 1000 : 3000);
if (r.status === 304 && hit) {
hit.expires = expires;
return hit.body as T;
}
const body = await r.json();
const etag = r.headers.get("etag");
if (etag) this.store.set(url, { etag, body, expires });
return body as T;
}
}Combine with a stale-while-revalidate background refresh if you want
sub-max-age freshness without adding latency to the read path.