DEV Community

Cover image for Serving Real-Time Forex Rates Without Hammering Your API: A FastAPI + MongoDB Caching Strategy
Vaneesa Lenz
Vaneesa Lenz

Posted on • Originally published at float-forex.hashnode.dev

Serving Real-Time Forex Rates Without Hammering Your API: A FastAPI + MongoDB Caching Strategy

Any app that serves live data — currency rates, crypto prices, stock tickers, weather — eventually hits the same wall. Your upstream data provider has a rate limit. Your users don't. If every page load triggers a fresh call to the provider, you burn through your quota in minutes, your response times spike, and your bill (or your free tier) evaporates.
I ran into this building a forex-rate app that serves live exchange rates for 150+ currencies. This article is about the caching layer that sits between the upstream rate provider and the users — how it decouples "how often users ask" from "how often we fetch," and the specific decisions that make it work without serving dangerously stale numbers.
The stack is FastAPI (Python) and MongoDB, but the pattern applies to any backend serving read-heavy live data.
The Problem, Stated Precisely
The naive implementation looks like this:
python@app.get("/api/rates")
async def get_rates(base: str = "USD"):
# Called on EVERY user request
data = await upstream_provider.fetch(base)
return data
The issue is the coupling: one user request equals one upstream fetch. With even modest traffic, this fails in three ways at once.

Rate limits. Most FX data providers cap you — say, 1,000 requests/day on a free or low tier. A few hundred daily visitors, each loading a couple of pages, blows through that before noon.
Latency. The upstream call is the slowest part of your response. Making every user wait for it means every page is as slow as the provider's worst moment.
Cost and fragility. You're paying (in money or quota) for redundant fetches — 50 users asking for the USD rate in the same minute triggers 50 identical upstream calls that all return the same numbers.

The key insight: exchange rates don't change meaningfully every second. For most currency pairs, a rate that's two minutes old is perfectly usable. So there's no reason to fetch on demand. The fetch frequency should be driven by how fast the data actually changes, not by how often users ask for it.
The Core Pattern: Decouple Fetch from Serve
The fix is a caching layer with two independent loops:

A background refresh loop that fetches from the upstream provider on a fixed schedule (say, every 2 minutes) and writes the result to MongoDB.
The request handler that only ever reads from MongoDB — never touches the upstream provider directly.

Now the math changes completely. The upstream provider gets hit on a fixed schedule — 30 times an hour, regardless of whether you have 5 users or 5,000. User requests are served from a fast local database read. The two are fully decoupled.

python# The request handler NEVER calls upstream.
@app.get("/api/rates")
async def get_rates(base: str = "USD"):
doc = await db.rates.find_one({"base": base})
if not doc:
# Cold start only — see "handling cold start" below
return {"error": "rates warming up, retry shortly"}, 503
return {
"base": base,
"rates": doc["rates"],
"timestamp": doc["updated_at"],
}
The read is a single indexed MongoDB lookup — typically single-digit milliseconds — instead of a network round-trip to a third-party API.
Building the Background Refresh Loop
FastAPI's lifespan context is the clean place to start and stop a background task tied to the app's life. The task loops forever, fetching and caching on an interval.
pythonimport asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI

REFRESH_SECONDS = 120 # 2 minutes
`
async def refresh_rates_loop():
while True:
try:
for base in ("USD", "EUR", "GBP"): # base currencies you serve
data = await upstream_provider.fetch(base)
await db.rates.update_one(
{"base": base},
{"$set": {
"rates": data["rates"],
"updated_at": data["timestamp"],
}},
upsert=True,
)
except Exception as e:
# Never let a transient upstream error kill the loop
logger.error(f"rate refresh failed: {e}")
await asyncio.sleep(REFRESH_SECONDS)

@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(refresh_rates_loop())
yield
task.cancel() # clean shutdown
app = FastAPI(lifespan=lifespan)`

Two things worth noting here. First, the try/except inside the loop is not optional — if the upstream provider has a hiccup and you let the exception propagate, the entire refresh loop dies silently and your cache goes stale forever while still serving 200s. Catch, log, and keep looping. Second, upsert=True means you don't need to pre-seed the database; the first successful fetch creates the document.
Handling Cold Start
There's a gap: when the app first boots, the refresh loop hasn't run yet, so MongoDB is empty. For a few seconds, find_one returns nothing.
You have three options, in increasing order of polish:

Return a 503 ("warming up") and let the client retry. Simplest, but users see a blip on deploy.
Trigger one synchronous fetch on startup before serving traffic — the loop's first iteration runs inside lifespan before the yield. Removes the gap at the cost of slightly slower boot.
Persist the cache so it survives restarts. Because the data is in MongoDB (not in-memory), it already does — after the first ever run, a redeploy reads the last known rates immediately while the loop refreshes them. This is a real advantage of using the database as the cache rather than an in-process dict.

Option 3 is the quiet win of database-backed caching: your cache is durable. A restart doesn't cold-start from zero; it starts from the last good data.
Staleness: The Decision That Actually Matters
Serving cached data means sometimes serving old data. The important design question is: how old is too old, and what do you do about it?
The answer depends entirely on the volatility of what you're serving. For forex, I attach the updated_at timestamp to every response and let the client decide how to present it — showing "updated 2 minutes ago" is honest and usually fine. But there's a threshold beyond which stale data becomes misleading rather than merely old.
So the handler flags staleness rather than hiding it:
pythonfrom datetime import datetime, timezone

STALE_AFTER_SECONDS = 600 # 10 minutes

`@app.get("/api/rates")
async def get_rates(base: str = "USD"):
doc = await db.rates.find_one({"base": base})
if not doc:
return {"error": "rates warming up"}, 503

age = (datetime.now(timezone.utc) - doc["updated_at"]).total_seconds()
return {
    "base": base,
    "rates": doc["rates"],
    "timestamp": doc["updated_at"],
    "stale": age > STALE_AFTER_SECONDS,  # let the client react
}`
Enter fullscreen mode Exit fullscreen mode

If stale is true — meaning the refresh loop has failed for longer than expected, perhaps the upstream provider is down — the client can show a warning instead of silently presenting numbers that might be far off. This is the difference between "slightly old data" (fine, expected) and "data so old it's wrong" (needs to be surfaced). The cache doesn't just store data; it stores when the data is from, and that timestamp is a first-class part of the response.
Why MongoDB Over an In-Memory Cache or Redis?
A reasonable question — Redis is the reflexive choice for caching. For this workload, the database made more sense for a few reasons.
The data is small and structured (a document per base currency), read far more than written, and benefits from durability across restarts. MongoDB gave persistence, a single datastore to operate instead of two, and query flexibility for the historical rate data the same app already stored. Redis would be the better call if the cache were huge, needed sub-millisecond reads at very high throughput, or required TTL-based auto-expiry. For a few base-currency documents refreshed every couple of minutes, a plain indexed MongoDB collection is simpler and one less moving part.
The general principle: don't reach for a dedicated cache store until the workload actually demands it. "Cache" is a role, not a technology — any fast, durable read store can play it.
The Results
The before-and-after is stark. Under the naive model, upstream calls scaled linearly with traffic and the free-tier rate limit was a hard ceiling on how many users the app could serve. Under the decoupled model, upstream calls are constant — a fixed number per hour set by REFRESH_SECONDS, completely independent of traffic. The app can serve 10 users or 10,000 with the same upstream footprint, response times are dominated by a fast local read, and a provider outage degrades gracefully (stale-flagged data) instead of failing hard.
To recap the pattern:

Decouple fetch from serve. A background loop fetches on a schedule; request handlers only read the cache.
Use a durable cache so restarts don't cold-start from zero.
Make the timestamp a first-class citizen. Track data age and flag staleness rather than hiding it.
Protect the refresh loop with try/except so a transient upstream error can't silently kill it.
Match the refresh interval to how fast the data actually changes — not to how often users ask.

None of this is exotic. It's about 40 lines of background task plus a timestamp check. But it's the difference between an app that falls over at modest traffic and one that serves live data smoothly at any scale.

I build data-heavy web apps with React, FastAPI, and MongoDB. This caching pattern runs in production serving live rates for 150+ currencies on FloatForex, a real-time forex-rate project I work on.

Top comments (0)