The currency converter on monu.tools needs daily exchange rates. The obvious way to get them is to call an upstream API on each request. That works until the day the upstream is slow, rate-limited, or down, and then every visitor sees an error for something that has not actually changed since yesterday.
The fix is to stop reading from the upstream on the request path at all.
The shape of the problem
Two things were tangled together: fetching fresh rates, and serving them. When they share a code path, a failure in the first breaks the second. Separating them means a bad fetch is invisible to users, because the last good table is still sitting there ready to serve.
Two Workers, one KV namespace
A small standalone cron Worker fetches the rates on a schedule and writes them to a KV namespace. It only overwrites on success:
async function refresh(env: Env) {
const res = await fetch(SOURCE)
if (!res.ok) return { ok: false, reason: `upstream HTTP ${res.status}` }
const payload = parseRates(await res.json())
if (!payload) return { ok: false, reason: "unparseable upstream response" }
// Only write on success, so a bad upstream never clobbers good data.
await env.RATES.put("latest", JSON.stringify(payload))
return { ok: true }
}
That last comment is the whole point. If the upstream returns garbage, the Worker returns early and KV keeps yesterday's known-good table.
The site's /api/rates route then reads KV first and only falls back to a live
proxy if KV is empty:
const payload = (await fromKv()) ?? (await fromUpstream())
Because the fallback exists, deploying the route before KV is wired up is a no-op. It keeps proxying the upstream exactly as before, and the KV path lights up the moment the binding is added. No flag day.
Verifying it actually reads KV
The catch: when both KV and the upstream are healthy, the responses are identical, so "it works" tells you nothing about which source answered. To prove the KV path was live after deploy, I wrote a sentinel value into KV (an extra, obviously-fake currency code), requested the endpoint with a cache buster, and checked whether the sentinel came back. It did, which meant the site was reading KV and not the upstream. Then I reseeded clean data immediately.
A 429 from the submission endpoint, or an identical-looking response, can hide a broken wire. A deliberate sentinel that only exists in one source removes the ambiguity.
What this buys
- Every user request is decoupled from the upstream API.
- An upstream outage degrades to "yesterday's rates" instead of an error.
- The refresh cadence is a one-line cron change, independent of the site.
None of this is exotic. It is just moving the fetch off the request path and being honest about how you verify the result. You can see it running on the monu.tools currency converter.