Cache API responses in production

Build production API caching: canonical cache keys, TTLs set by data volatility, single-flight, stale-while-revalidate, negative caching and a shared Redis cache.

  • 40 min
  • Advanced

The Fundamentals course ended with a small TTL cache: a Map, a sorted key, the pending Promise stored so concurrent callers share one call, failures evicted. That is the right shape for one process. It starts to leak money the moment the integration grows up:

  • Ten server instances each keep their own Map, so a cold deploy pays ten times for the same country record.
  • Callers build the same question in different ways (USD and usd, a stray utm_source, parameters in another order) and every variant is a separate entry, a separate call, and a separate charge.
  • An entry expires at 09:00:00 and every request in that second waits on the refresh, so your p99 latency has a sawtooth that lines up with your TTLs.
  • A lookup that finds nothing is never cached, so a bad value typed into a form is sent upstream on every keystroke.

This lesson fixes each of those. The pieces are independent; take the ones your traffic needs.

Decide the TTL from how fast the data moves

A TTL is a claim about the source: "this answer will still be true for N seconds". Get it from the data, not from a round number. Five classes cover almost every APIVerve endpoint:

ClassExampleWhat changes itStarting TTL
Pure function of the inputNumber to Words, Hash Generator, Query String BuilderNothing. Same input, same output, foreverNo expiry, bounded by size
Reference dataCountry Lookup, currency symbols, ISO codesA treaty, a new currency. Years7 to 30 days
Slowly moving recordsDNS, WHOIS, company records, job listingsThe owner edits them. Hours1 to 24 hours
Live valuesExchange rates, weather, market dataThe world, continuouslySeconds to minutes, tied to the source's own update time
Per-request generation or verdictsRandom data, UUIDs, Email ValidatorEvery call is meant to differ, or describes this instantDo not cache

Two refinements matter at scale.

Read the source's own clock when it has one. Exchange Rate returns lastUpdated. If the rate was refreshed four minutes ago and the source updates every five, a five-minute TTL from your fetch time can serve a value that is nine minutes old. Expire relative to the source's timestamp when the response carries one, and fall back to your fetch time when it does not.

Add jitter. If a deploy warms 50,000 keys in the same minute with the same 24-hour TTL, they all expire in the same minute tomorrow, and you get a stampede of misses at the same hour every day. Spread expiry with a random 10 percent:

ttl.mjs
export const ttlWithJitter = (ms, spread = 0.1) => Math.round(ms * (1 - spread / 2 + Math.random() * spread));

Build one canonical key for one question

A cache key must be equal exactly when the answers would be equal. Two failure modes pull in opposite directions: a key that is too specific splits one question into many entries (you pay for each), and a key that is too general merges different questions (you serve one user another user's answer). The second is a correctness bug, so every normalisation step must be one you are sure the API treats as equivalent.

Start with the query string. ?a=1&b=2 and ?b=2&a=1 are the same request, but string comparison says otherwise. A canonical query string sorts the keys, encodes every value the same way, skips parameters that carry no value, and has one fixed rule for arrays:

query.mjs
export function buildQuery(params = {}) {
  return Object.keys(params)
    .filter((k) => params[k] !== null && params[k] !== undefined)
    .sort()
    .map((k) => {
      const v = params[k];
      const encoded = Array.isArray(v)
        ? v.map((item) => encodeURIComponent(String(item))).join(',')
        : encodeURIComponent(String(v));
      return `${encodeURIComponent(k)}=${encoded}`;
    })
    .join('&');
}

buildQuery({ b: 2, a: 1 });                          // "a=1&b=2"
buildQuery({ a: 1, b: 2, c: undefined, d: null });   // "a=1&b=2"
buildQuery({ text: 'rock & roll', algorithms: ['sha256', 'md5'] });
// "algorithms=sha256,md5&text=rock%20%26%20roll"

Each rule has a reason:

  • Sorted keys make parameter order irrelevant. The default sort() compares UTF-16 code units, which is deterministic on every machine, and that is all a key needs.
  • encodeURIComponent on every value means rock & roll can never be read as two parameters, and the same value always produces the same bytes. Pick one encoder and use it everywhere: URLSearchParams writes a space as + while encodeURIComponent writes %20, so mixing them splits the cache.
  • null and undefined are skipped, so an optional filter the caller left unset produces the same key as a caller who never mentioned it. An empty string is kept: '' is a value, and on some APIs it means something.
  • Arrays are encoded per item and joined with a literal comma. Many APIVerve parameters take comma-separated lists, such as Hash Generator's algorithms=sha256,md5. Encoding each item first means a comma inside one item becomes %2C, so ['a,b'] and ['a', 'b'] stay distinct keys. Item order is kept, because for some parameters order means something.

APIVerve's own Query String Builder is useful for checking your encoding against a second implementation. Note what it does with order:

Try it liveQuery String Builder1 credit
POSThttps://api.apiverve.com/v1/querystringbuilder
curl -X POST "https://api.apiverve.com/v1/querystringbuilder" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"params":{"to":"EUR","from":"USD"}}'
Sign in or create a free account to run this with your own key.

data.queryString keeps the order you gave: to=EUR&from=USD. A builder that preserves order is right for a URL and wrong for a cache key, which is why buildQuery sorts first.

Normalise per API, then drop what the API ignores

Sorting fixes order. It does not fix usd versus USD, or a tracking parameter that rode in from a URL. Put per-API normalisers in front of buildQuery:

cache-key.mjs
import { buildQuery } from './query.mjs';

// Declared parameters per API, from each reference page. Anything else is dropped.
const DECLARED = {
  countrylookup: ['country', 'majorcities'],
  exchangerate: ['currency1', 'currency2', 'date'],
};

const NORMALISE = {
  exchangerate: (p) => ({ ...p, currency1: p.currency1?.trim().toUpperCase(), currency2: p.currency2?.trim().toUpperCase() }),
};

export function cacheKey(api, params) {
  const allowed = DECLARED[api];
  const kept = allowed ? Object.fromEntries(Object.entries(params).filter(([k]) => allowed.includes(k))) : params;
  const normalised = NORMALISE[api] ? NORMALISE[api](kept) : kept;
  return `${api}?${buildQuery(normalised)}`;
}

The allow-list is not tidiness. APIVerve silently drops parameters an endpoint does not declare: no 400, the call simply runs without them. So country=JP&ref=newsletter returns exactly what country=JP returns, and keeping ref in the key pays twice for one answer. The same silence cuts the other way. A misspelt parameter (majorCities for majorcities) is dropped too, so the API answers a different question than you meant, and the key, which includes the typo, faithfully caches the wrong answer. Building keys from the declared list catches that in review, because the typo vanishes from the key.

Upper-casing currency codes is safe because ISO 4217 codes are defined in capitals. Lower-casing an email address is not obviously safe: the domain is case-insensitive, the local part technically is not. When you are unsure, do not normalise; an extra entry costs credits, a merged entry costs correctness.

Keys can get long (a 2,000-character text parameter is a 2,000-character key). Hash them for storage, and keep the API id as a readable prefix so you can still scan and expire by API:

Try it liveHash Generator1 credit
GEThttps://api.apiverve.com/v1/hashgenerator
curl "https://api.apiverve.com/v1/hashgenerator?text=countrylookup%3Fcountry%3DJP&algorithms=sha256" \
  -H "x-api-key: YOUR_API_KEY"
Sign in or create a free account to run this with your own key.

data.hashes.sha256 is a fixed 64-character key for any input length. Store it as countrylookup:<hash>.

In Node the same digest is one line, with no call needed in production: createHash('sha256').update(key).digest('hex') from node:crypto.

Check yourself

Your cache key is built as api + '?' + new URLSearchParams(params). Hit rate on Exchange Rate is far lower than expected. Which change fixes the most likely cause without risking wrong answers?

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales