How to Cache Exchange Rates Without Showing Stale Prices

6 min read

How to Cache Exchange Rates Without Showing Stale Prices

You should cache exchange rates, but only for as long as your use case tolerates. For a product page, a few minutes is usually fine. For a crypto widget, seconds. For an invoice, you do not cache at all: you fetch once, store the rate with the document, and never look it up again.

This article shows how to pick a time-to-live (TTL) for each case, how to avoid the classic mistakes (cache stampedes, silent staleness, float math), and how to build a small cache in Node.js that uses the as_of and status fields of the response to decide what is safe to show.

Why cache exchange rates at all?

A rate lookup on every page view looks harmless until traffic grows. Three things go wrong:

  • Quota burn. Every call costs a request unit. A catalog page with 40 prices on it should not cost 40 units per visitor.
  • Rate limits. Plans have a per-second limit. A burst of traffic or a cron job that fans out can trigger rate_limit_exceeded (HTTP 429).
  • Latency and availability. An extra network hop in the render path makes pages slower, and an upstream hiccup becomes your outage.

Caching fixes all three. The risk is the opposite problem: showing a price built on a rate that is much older than you think. The rest of this article is about getting that balance right.

Know how often the data actually changes

A cache TTL shorter than the data's refresh interval buys you nothing, and one far longer than it costs accuracy. With GetRatesAPI, crypto prices refresh every 10 seconds, and fiat currencies and metals (XAU, XAG) every 60 seconds. Each rate also carries two fields that make caching safer:

  • as_of: the time of the observation, not the time you fetched it.
  • status: fresh or stale. Crypto is stale after 1 minute, fiat after 5 minutes. Stale rates are flagged, not withheld, so your code decides what to do with them.
{"base":"EUR","quote":"USD","rate":"1.0842","as_of":"2026-01-02T10:00:00Z","status":"fresh","type":"direct"}

(The numbers here are an example, not a current rate.)

One more detail: a rate with "type": "derived" is computed through USD, EUR or BTC, and its as_of is its oldest input. When you judge age, always use as_of, never the moment your server received the response.

Choose a TTL by use case

There is no universal number. Match the TTL to what a wrong rate would cost you.

Use caseSuggested cache lifetimeWhy
Informational price display (catalog, "approx. in your currency")5 to 15 minutesPrices are indicative; small drift is acceptable.
Checkout quoteFetch fresh, then lock for 10 to 15 minutesThe customer must see one stable number from cart to payment.
Invoice or accounting entryDo not cache; store the rate on the documentThe rate is part of the record and must never change afterward.
Fiat dashboard60 secondsMatches the refresh interval.
Crypto price widget10 to 30 secondsMatches the 10-second refresh; longer feels frozen.
Background reportsFetch once per runConsistency inside one run matters more than freshness.

The checkout and invoice rows are worth repeating, because they are where money is lost. Never recompute a charged amount from a cache that may have moved. Save the rate, the as_of and the amounts as strings next to the order, and treat them as immutable.

Count your request units before you pick a refresh rate

Say you display prices in a handful of currencies against one base. A single refresh job can fetch up to 20 quotes against one base in one call, which still costs 1 request unit:

curl -H "Authorization: ApiKey $GETRATES_API_KEY" \
  "https://api.getratesapi.com/v1/rates/USD?quote=EUR,GBP,JPY"

Unsupported quotes are listed in an unsupported field instead of failing the whole call. If that job runs every 60 seconds, it uses 1,440 units per day, about 43,000 per month, regardless of how many visitors you have. Every 5 minutes, it is about 8,600 per month. Compare that with your plan's quota on the pricing page, and check your live consumption with the usage endpoint.

This is the main design choice: refresh on a schedule, serve from the cache. Your cost then depends on the schedule, not on traffic.

A small Node.js cache that respects as_of and status

The example below caches per pair, shares one in-flight request between concurrent callers (which prevents a stampede when the entry expires), and falls back to an older cached value when the API fails, but only up to a hard limit. It requires Node 18 or later for fetch.

const API = "https://api.getratesapi.com";
const cache = new Map();    // "USD/EUR" -> { rate, fetchedAt }
const inflight = new Map(); // "USD/EUR" -> Promise

async function fetchRate(base, quote) {
  const res = await fetch(`${API}/v1/rates/${base}/${quote}`, {
    headers: { Authorization: `ApiKey ${process.env.GETRATES_API_KEY}` },
  });
  const body = await res.json();
  if (!res.ok) {
    const err = new Error(body.code ?? "http_error");
    err.code = body.code;
    err.retryAfter = body.retry_after_seconds;
    throw err;
  }
  return body;
}

async function getRate(base, quote, { ttlMs = 60_000, maxAgeMs = 3_600_000 } = {}) {
  const key = `${base}/${quote}`;
  const hit = cache.get(key);
  const age = hit ? Date.now() - hit.fetchedAt : Infinity;

  if (hit && age < ttlMs) return { ...hit.rate, fromCache: true };

  if (!inflight.has(key)) {
    const p = fetchRate(base, quote)
      .then((rate) => {
        cache.set(key, { rate, fetchedAt: Date.now() });
        return rate;
      })
      .finally(() => inflight.delete(key));
    inflight.set(key, p);
  }

  try {
    return { ...(await inflight.get(key)), fromCache: false };
  } catch (err) {
    // Serve an older value only within a hard limit, and say so.
    if (hit && age < maxAgeMs) {
      return { ...hit.rate, fromCache: true, degraded: true };
    }
    throw err;
  }
}

A few choices in this code are deliberate:

  • maxAgeMs is a hard ceiling. Serving a 10-minute-old rate during a brief outage is reasonable for a catalog. Serving a 3-day-old one is not. Past the ceiling, fail loudly.
  • The degraded flag lets the UI add "approximate" wording or hide the converted price rather than pretend everything is normal.
  • Errors are matched on code, never on message text. See the error reference for the full list, including temporarily_unavailable (503) and rate_limit_exceeded (429, with retry_after_seconds).

Check status before you trust a value

Before showing or charging anything, inspect the response itself:

const r = await getRate("USD", "EUR");

if (r.status === "stale") {
  // Flagged by the API: observation is older than the freshness window.
  // For a catalog, show with an "approximate" label.
  // For checkout, refuse to lock a quote and retry.
}

Because a cached copy keeps its original as_of, a fresh rate that sat in your cache for ten minutes is no longer fresh by the API's definition. If you need strictness, compare as_of with the current time yourself instead of relying on the cached status.

Do the math with decimals, not floats

Rates and amounts arrive as JSON strings so you can keep exact decimals end to end. Do not pass them through parseFloat. In Python, for example:

from decimal import Decimal, ROUND_HALF_UP

rate = Decimal("1.0842")      # from the "rate" string
amount = Decimal("125.50")
converted = (amount * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

Results from the API are not rounded, so the rounding rule is yours to choose and document. Round once, at the end, and store the rounded value you actually charged.

If you only need a single conversion, the convert endpoint returns an exact converted_amount and spares you the multiplication.

A short checklist

  • Refresh on a schedule and serve visitors from the cache.
  • Pick the TTL from the use case, not from habit.
  • Use the batch call (up to 20 quotes per base) to keep refresh jobs at 1 unit.
  • Deduplicate concurrent refreshes so an expiry does not trigger a stampede.
  • Judge age by as_of, and treat status: "stale" as a signal, not an error.
  • Cap how long you will serve old data during an outage.
  • Store the rate on every order and invoice; never recompute from the cache.
  • Keep rates and amounts as strings or decimals, and round once.

Try it on the free plan

You can test all of this without a credit card. Create a free account, generate an API key in the dashboard, and try the endpoints in the built-in playground. The current rate reference documents every field mentioned here, and the rate limits page explains how to size your refresh job against your plan.

All articles →
Cache Exchange Rates: Safe TTLs and Code Examples — GetRatesAPI