A currency converter has one job that looks trivial and one job that is genuinely hard. The trivial job is multiplication: amount × reference rate = converted result. The hard job is freshness: making sure the rate you display is the rate you claim it is, and saying so when it is not.
Disclosure: I built the converter this post is about — a free, browser-only Currency Converter (https://toolkitloop.com/currency-converter) on ToolkitLoop. No signup, and the amount you type never leaves the browser. The caching decisions below are the ones I actually made.
The pipeline: Frankfurter v2 behind a server-side cache
The tool uses Frankfurter v2, which aggregates institutional reference data, requested through a server-side cache. The default cache is one hour. That sentence is the whole pipeline, and every word in it is a decision.
Why a server-side cache at all? Because the provider publishes rates on its own schedule, not on yours. Fetching live on every conversion would hammer the provider, add latency to every keystroke, and — worst of all — give you a different answer for the same pair depending on which second you asked. A one-hour cache gives the page a stable number to show, and a stable number is a number a user can plan around.
But a cache is also a lie waiting to happen. Every cached rate is a rate that is ageing, and the moment you stop showing the age, you are presenting old data as new. So the cache carries a rule with it: if the provider is temporarily unavailable, an older usable cache is explicitly labelled as old rather than presented as new data. The label is not a nice-to-have. It is the difference between a tool that is honest about staleness and one that silently serves a stale rate.
// The shape of the decision, not the production code:
// a cached rate is only trustworthy if its age is visible.
const cachedRate = {
pair: "USD/EUR",
rate: 0.85, // example only — never a live rate
rateDate: "2026-11-10", // when the provider published it
fetchedAt: "2026-11-10T10:00:00Z", // when we retrieved it
cacheAgeMinutes: 42,
cacheStatus: "fresh", // or "stale — labelled, never hidden"
};
function isUsable(entry, maxAgeMinutes = 60) {
if (entry.cacheStatus === "stale") {
return { usable: true, labelled: true, reason: "old cache, shown as old" };
}
return { usable: entry.cacheAgeMinutes <= maxAgeMinutes, labelled: false };
}
Two timestamps, not one
The page shows the rate date separately from the fetch time, and the distinction matters more than it looks. The rate date is when the provider published the rate. The fetch time is when this site retrieved it. A pipeline that conflates them will happily display yesterday's rate with today's timestamp, and nobody will notice until a user compares it against their card statement.
If you build anything that displays financial data, keep both clocks visible in your data model, not just in the UI. The moment they collapse into a single "updated at" field, you have lost the ability to answer the only question that matters: how old is the number I am looking at?
This is the same reason the cache status is a first-class field, not a tooltip. The data model carries rateDate, fetchedAt, and cacheStatus as separate values, and the UI renders all three. A developer reading the model can see the freshness story without opening the page. A user reading the page can see it without opening the model.
The request boundary: the pair crosses, the amount does not
The second architectural decision is the request boundary. Only the selected currency pair is requested to retrieve the reference rate. The amount never leaves the browser. The server receives currency codes, never the raw amount.
That constraint shaped the implementation more than any framework choice. The formula is trivial; the interesting code is the boundary: what crosses the network (two currency codes) and what never does (everything else). Swapping the currencies recalculates immediately from the loaded reference rate, with no new request for the amount — because the amount was never sent in the first place.
There is a performance consequence too. Because the rate is fetched per pair and cached for an hour, swapping USD/EUR to EUR/USD is a local recalculation, not a second network round trip. The cache key is the pair, not the user, so there is no per-user state to store, no conversion history to leak, and no account system to maintain. The privacy story and the performance story are the same story: keep the request small, keep the state on the client.
What a stale cache is allowed to do
The rule I settled on: a stale cache may be shown, but it must be labelled, and it must never be presented as fresh. That sounds obvious. In practice it is a design argument, because the labelled version looks worse. A page that says "this rate is 3 hours old" feels less trustworthy than a page that says nothing — even though the silent page is showing you the same 3-hour-old rate.
I chose the labelled version, for the same reason the tool shows both timestamps. A rate that admits its age lets a careful user re-check. A rate that hides it invites a false sense of precision. And there is a practical upside: when the provider is down and the label appears, users learn that the tool is honest about its data source, which is worth more than the illusion of always-fresh data.
The failure mode to avoid is the opposite one: refusing to show anything when the provider is down. A converter that goes blank during an outage is useless at exactly the moment a traveller needs it — standing at a terminal, deciding whether to accept a conversion. A labelled old rate, with its age visible, is a better answer than no answer.
What is deliberately missing
Two features are absent on purpose, and both are documented on the page rather than hidden.
Historical-date and trend analysis do not exist yet. This first release focuses on a transparent current/reference conversion, and history will be added to the same tool rather than as empty or duplicate pages. A converter that fakes history with today's rate would be worse than one that admits the gap.
There is also no fee mode yet. Card and ATM fees vary by product, location, and transaction, so the tool does not assume a fixed percentage. A future fee mode will let you enter your own assumptions and clearly distinguish its estimate from the reference rate. Both gaps are honesty decisions, not roadmap accidents.
The question I have not settled
Here is the open question, and the one I would argue about over coffee: when a user converts a 14-line trip budget, should each line be rounded, or should only the total be rounded once at the end? The displayed result is rounded for readability; the underlying calculation keeps full precision. Per-line rounding matches bank statements; single rounding at the end is mathematically cleaner. The difference is small per line and real across a trip.
How do you handle it in your own tools — round at display, round at storage, or never round until the final total? And have you ever caught a rate that was silently stale — in a converter, a dashboard, or anywhere else a cache was hiding its age? I am curious which failure you have seen more: a cache that lied about freshness, or a tool that went blank instead of showing an old number.
Disclosure: I built the Currency Converter (https://toolkitloop.com/currency-converter) discussed in this post. It is free, runs in the browser, and needs no signup.
About the author
- Founder of ToolkitLoop (toolkitloop.com) — 30 free online tools that run 100% in your browser. No sign-ups, no uploads, private by design.
Comments
Join the discussionNo comments yet. Be the first to comment.
