Guides

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 304

Treat 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:

Tiermax-agestale-while-revalidateUsed by
HEARTBEAT1 s9 sGET /chain/head
BLOCK3 s9 sIndexer-fresh routes (/tokens/..., /token/{addr}/price, /dex/pools, /portfolio/..., /rakeback/{addr}, /analytics/overview, /analytics/*/series, /stats/series)
CRON30 s90 sCron-derived routes (/analytics/breakdown, /swap/candidates, /explore/creators, /points/leaderboard, /points/weeks, /points/leagues)
STATIC300 s900 sConfig-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 Vary is 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.

On this page