How to Cache Exchange Rates and Stay Under Forex API Rate Limits
Pick TTL by use case, coalesce cache misses, and budget a 400-requests-per-5-minutes forex API window without starving tickers or converters.
Polling a forex API on every page view, keystroke, or fan-out is the fastest path to HTTP 429. FX quotas are usually sliding windows, so a burst of uncached reads from a converter or a multi-pair dashboard can exhaust a limit such as 400 requests per five minutes before the window rolls forward. The product still needs a current EUR/USD live rate; it does not need a unique upstream call for every user.
Caching decides how that quota is spent. TTL sets freshness, coalescing sets how many readers share a miss, and Redis versus memory sets whether each instance burns the limit alone. Those choices determine whether a ticker, a converter, and a batch job can share one plan.
Key takeaways
- Match TTL to the surface: about 1 second for tickers, seconds to minutes for dashboards, up to 1 hour for converters and invoices.
- Cache per currency pair (optionally with a timestamp), not as one unbounded blob of every quote.
- Coalesce in-flight misses so N concurrent users trigger a single upstream call.
- Treat a 400-requests-per-5-minutes window as a budget; prefer stale-if-error over failing the request.
- Use in-process memory for one instance; Redis when several servers must share one quota.
Why forex API rate limits fail in production
Most FX APIs meter HTTP requests, not unique pairs. Ten users loading EUR/USD, GBP/USD, and USD/JPY as three calls produce thirty hits, not three. A one-second loop in one browser tab can burn 300 requests in five minutes and leave a 400-request window with almost no headroom.
Windows are typically sliding. Capacity returns only as old timestamps drop out, and immediate 429 retries extend the outage. Caching, backoff, and coalescing keep the window from filling; a larger plan does not fix a thundering herd.
Choosing TTL by use case
TTL is a product decision. A one-second cache on an invoice job wastes quota. A one-hour cache on a trading ticker misleads the user. The correct default is the longest interval each surface can tolerate, not the shortest the API can deliver.
| Use case | Freshness need | Typical TTL | If left uncached |
|---|---|---|---|
| Live ticker / sparkline | Tick-level movement | 1 second | 60 requests/min per pair |
| Ops or trading dashboard | Near real time | 5–15 seconds | Multiplies by visible pairs |
| Website converter / checkout FX | Mid-market is enough | 5–60 minutes, often 1 hour | One request per keystroke or view |
| Invoicing, ERP, accounting | Stable for the document | 1–24 hours | A request per line item |
| Nightly portfolio revaluation | Consistent snapshot | Cache the snapshot for the job | One request per holding |
Tickers versus converters
A public ticker for EUR/USD can justify a 1-second TTL because users watch the number move, but every WebSocket or SSE subscriber should read a shared last tick, not call upstream. A checkout converter is the opposite: mid-market FX for an approximate cart total can sit for an hour, and teams that pin the rate onto the order at quote time can ignore later freshness entirely.
The quota math
A 400-request / 300-second window averages 1.33 requests per second. Twenty pairs at a 1-second TTL as separate calls need 20 requests per second—about fifteen times that budget. Use a batch endpoint, or raise per-pair TTL: 20 pairs fit in the window only if each is fetched at most every 15 seconds (20 × 300 / 400).
In-memory and Redis caching patterns
Key by the unit of freshness: fx:rate:{base}:{quote}, with an optional source suffix. Store bid, ask, mid, and the provider timestamp, not mid alone. Tickers need the timestamp to show age; invoicing jobs need it for audit.
Single process: in-memory
A process-local dict (or TTL LRU) is enough when one instance owns all traffic: return if younger than TTL, else fetch and write. Five workers mean five caches, so a hot pair is fetched five times per TTL and a rolling deploy stampedes the API.
Multiple instances: Redis
Redis, Valkey, or Memcached makes the quota shared. GET on the hit path; on miss, fetch and SET ... EX {ttl} with the product TTL (EX 1 for a ticker, EX 3600 for a converter). Do not pack every pair into one blob: a 1-second refresh of 150 symbols either over-fetches unused pairs or forces every consumer onto the shortest TTL. A lock such as SET fx:lock:eur:usd NX EX 2 is Redis coalescing. The winner fetches; others wait and read the rate key. Serve the previous value while the lock is held (stale-while-revalidate) so a ticker never blanks.
Request coalescing: N users, one upstream call
TTL does not stop a thundering herd. When a 1-second EUR/USD entry expires, concurrent misses can all hit upstream unless coalesced (singleflight): the first caller fetches; later callers await the same Future. In-process that is a pair-to-Future map; in Redis it is the lock key above. The invariant is at most one in-flight upstream request per pair per TTL.
import asyncio, time
class FxCache:
def __init__(self, ttl, max_req=400, window=300):
self.ttl, self.max_req, self.window = ttl, max_req, window
self.store, self.inflight, self.hits = {}, {}, []
def _allow(self):
now = time.monotonic()
self.hits = [t for t in self.hits if now - t < self.window]
return len(self.hits) < self.max_req
async def get(self, pair, fetch):
now = time.monotonic()
cached = self.store.get(pair)
if cached and now - cached[0] < self.ttl:
return cached[1]
if pair in self.inflight:
return await self.inflight[pair]
if not self._allow():
if cached:
return cached[1] # stale-if-limited
raise RuntimeError("fx quota exhausted")
fut = asyncio.get_running_loop().create_future()
self.inflight[pair] = fut
try:
data = await fetch(pair)
self.hits.append(time.monotonic())
self.store[pair] = (time.monotonic(), data)
fut.set_result(data)
return data
except Exception as exc:
fut.set_exception(exc)
raise
finally:
self.inflight.pop(pair, None)The three returns before fetch are the design: fresh hit, join in-flight, and stale-if-limited. Only the remaining path spends quota. Replace the dict with Redis GET/SET and the same flow holds across instances.
Staying under a 400-requests-per-5-minutes window
Inventory every caller—ticker, converter, dashboard, cron, retries—and assign a budget. One workable split of 400/5 min is 240 for a batched ticker, 80 for converters, 40 for dashboards, and 40 for retries and deploys. The split can move; assigning it prevents silent overspend.
If the window is full, serve the last payload with a stale: true flag for display and converter flows; fail closed only when a fresh print is required. On 429, honor Retry-After with jittered backoff and no synchronized retries. Track requests per window, hit ratio, coalesced waits, and quote age. A 1-hour converter cache should exceed a 99 percent hit ratio; a 1-second ticker that still saturates the quota needs coalescing or a multi-pair refresh, not a larger plan.
FAQ
How long should exchange rates be cached?
Cache as long as the surface can tolerate: about 1 second for a live ticker, 5–15 seconds for a dashboard, and 5 minutes to 1 hour for converters and invoices. Nightly accounting jobs should reuse one snapshot for the whole run rather than refetching per line.
What is request coalescing for a forex API?
Coalescing means concurrent cache misses for the same pair share one in-flight upstream call. In one process this is a map of pair to Future; across processes it is a short Redis lock that prevents a thundering herd when a short TTL expires.
Should FX rates be cached in Redis or in memory?
In-memory is enough for a single instance and has the lowest latency. Redis (or Valkey/Memcached) is the default once more than one worker shares the same API key, so each process does not independently consume the sliding window.
What should happen when the 400-request window is exhausted?
Serve the last good quote with a staleness flag for display and converter flows, and fail hard only when a fresh print is required. Honor Retry-After, and do not retry from every instance at once.
Can one cache entry cover many currency pairs?
Only if they share a TTL and are always fetched together, such as a batched watchlist. Mixing a 1-second ticker pair and a 1-hour converter pair in one blob forces the converter onto the ticker TTL and wastes quota.
Plans and request volumes are listed on the Live-Rates plans page. Apply TTL, coalescing, and a shared cache so the quota is spent on freshness rather than duplicate reads.
Real-time forex rates for your app
Live bid/ask for the pairs you need, updated every second, with a simple JSON & XML API. Try it free for 7 days.