Guide/Currency converter architecture

How Do Currency Converter Apps Keep Exchange Rates Up to Date?

A currency converter stays current by fetching a complete rate observation, caching successful responses, refreshing on a controlled schedule, and preserving the market timestamp when a refresh fails or the market is closed.

ERexchangerate.dev·Aug 23, 2026·8 min read

A reliable converter does more than request a number on every page view. It stores the rate together with source, market_session, and data_updated_at, reuses that observation for a short period, and replaces it only after a successful refresh. The response time tells you when the API answered; the observation time tells you when the rate itself changed.

Key points
Cache the whole observation, including rates, sources, market_session, and data_updated_at.
Use data_updated_at for data age. timestamp records when the API built the HTTP response.
Actively traded rates can update intraday during the trading week; daily reference rates update on their own publication schedule.
During a weekend or market closure, keep the last valid observation and its original timestamp.
On a failed refresh, serve an explicitly dated last-known-good value or fail if no valid value exists.

Treat a rate response as one observation

A rate without its metadata is ambiguous. The same numeric value may be an intraday observation, a daily reference fix, or a derived cross. A converter should therefore store the response as one unit rather than extracting rates.EUR and discarding the rest. The API reference defines the response fields, and the methodology explains how source and session labels are assigned.

json · abbreviated latest responsecopy
{
  "base": "USD",
  "source": "live",
  "market_session": "open",
  "timestamp": "2026-08-23T09:42:06Z",
  "data_updated_at": "2026-08-23T09:41:00Z",
  "rates": { "EUR": 0.86124, "GBP": 0.74281 },
  "sources": { "EUR": "live", "GBP": "live" },
  "derived_symbols": []
}

source summarizes the least-fresh data class in a multi-currency response. Read the per-currency sources map when individual symbols can differ. derived_symbols identifies rates calculated through other currency legs rather than observed as a direct pair.

Two timestamps, two meanings
timestamp is the time the API assembled the response. data_updated_at is the time the underlying observation was last written. Display and freshness decisions should use data_updated_at.

Choose a refresh policy before writing the timer

There is no universal refresh interval. A checkout preview, a dashboard, and an overnight reporting job have different requirements. Start with the maximum data age your feature accepts, then cache for that period. Requesting on every render wastes calls and can still return the same observation.

Use caseTypical application policyWhat to show
Approximate storefront displayShared server cache; refresh every few minutesCurrency, indicative label, observation time
Operations dashboardShort cache; refresh while visibleRate, source, session, observation time
Daily reportOne scheduled fetch after the required fixRate and stored observation timestamp
Settlement or executable quoteDo not use an indicative converter rateUse the payment or dealing provider output

Actively traded currencies can update intraday through the trading week. Other currencies use daily reference series and move on their publication schedule. market_session is open, weekend, or interbank_closed; it lets the interface explain why an otherwise valid observation has not moved.

Build a small server-side cache in TypeScript

The cache below sorts the symbol list to create a stable key, stores only successful responses, and deduplicates concurrent refreshes. Keep authenticated calls on the server so the API key never enters a browser bundle.

typescript · latest-rates.tscopy
type RateSource = "live" | "ecb_daily" | "fred_daily";

type LatestRates = {
  base: string;
  source: RateSource;
  sources: Record<string, RateSource>;
  market_session: "open" | "weekend" | "interbank_closed";
  timestamp: string;
  data_updated_at: string;
  rates: Record<string, number>;
  derived_symbols: string[];
};

type CacheEntry = {
  value: LatestRates;
  fetchedAt: number;
};

const cache = new Map<string, CacheEntry>();
const pending = new Map<string, Promise<LatestRates>>();

function cacheKey(base: string, symbols: string[]): string {
  return `${base}:${[...symbols].sort().join(",")}`;
}

async function fetchLatest(base: string, symbols: string[]): Promise<LatestRates> {
  const url = new URL(`https://api.exchangerate.dev/v1/latest/${base}`);
  url.searchParams.set("symbols", [...symbols].sort().join(","));

  const response = await fetch(url, {
    headers: process.env.EXCHANGERATE_API_KEY
      ? { Authorization: `Bearer ${process.env.EXCHANGERATE_API_KEY}` }
      : {},
    signal: AbortSignal.timeout(8000),
  });

  if (!response.ok) {
    throw new Error(`FX refresh failed: ${response.status}`);
  }

  return response.json() as Promise<LatestRates>;
}

export async function getLatestRates(
  base: string,
  symbols: string[],
  maxAgeMs = 60_000,
): Promise<LatestRates> {
  const key = cacheKey(base, symbols);
  const existing = cache.get(key);

  if (existing && Date.now() - existing.fetchedAt < maxAgeMs) {
    return existing.value;
  }

  const inFlight = pending.get(key);
  if (inFlight) return inFlight;

  const refresh = fetchLatest(base, symbols)
    .then((value) => {
      cache.set(key, { value, fetchedAt: Date.now() });
      return value;
    })
    .finally(() => pending.delete(key));

  pending.set(key, refresh);
  return refresh;
}

This in-memory map is suitable for one long-running process. In serverless or multi-instance deployments, move the same cache entry to a shared store or use the hosting framework’s request cache. The data contract stays the same: value plus observation metadata, replaced only on success.

Handle a failed refresh without changing history

A timeout, 429 response, or upstream error is not a new market observation. Do not write zero, null, or the current clock time over the previous rate. If the feature allows last-known-good data, return the cached observation with its original data_updated_at and an application-level warning. If no valid observation exists, return an error.

typescript · explicit stale fallbackcopy
type ConverterResult =
  | { status: "fresh"; data: LatestRates }
  | { status: "last_known_good"; data: LatestRates; reason: string };

async function getConverterRates(
  base: string,
  symbols: string[],
): Promise<ConverterResult> {
  const key = cacheKey(base, symbols);

  try {
    return { status: "fresh", data: await getLatestRates(base, symbols) };
  } catch (error) {
    const previous = cache.get(key);
    if (!previous) throw error;

    return {
      status: "last_known_good",
      data: previous.value,
      reason: error instanceof Error ? error.message : "Refresh failed",
    };
  }
}

For 429 responses, respect Retry-After when present and stop all instances from retrying at once. Exponential backoff with jitter is appropriate for transient failures. Authentication errors need a configuration fix, not repeated retries.

Market closures are a valid state

The interbank market is closed over the weekend. A response labeled market_session: weekend can correctly carry the final observation from the trading week. Keep that label and the original data_updated_at; do not present a newly fetched HTTP response as a new weekend quote.

A converter may lengthen its application cache while the market is closed, but it should still refresh after the trading week resumes. Base that decision on the returned session state and your product requirements, not on a hardcoded assumption that every currency follows one schedule.

Convert with the stored rate and round once

For a response based on USD, converting 25 USD to EUR is 25 × rates.EUR. Keep the rate at its returned precision and round the final display amount according to the target currency. Do not round the rate first, because the error grows with larger amounts.

Indicative, not executable
A converter rate is suitable for display, estimates, and analytics. The amount charged or settled must come from the payment provider or dealing venue, including its spread, fees, rounding, and execution time.

Production checklist

  • Request only the symbols the screen needs.
  • Create a stable cache key from the base and sorted symbols.
  • Cache complete successful observations, never HTTP errors.
  • Deduplicate concurrent refreshes to prevent a cache stampede.
  • Display data_updated_at when the age of a rate matters.
  • Read the per-currency sources map for mixed responses.
  • Preserve market_session and derived_symbols.
  • Keep authenticated calls and API keys on the server.
  • Use last-known-good data only with its original timestamp and a clear state.
  • Use the settlement provider for final charged amounts.
ER
exchangerate.dev
Architecture and integration guides for developers building with FX data.

Keep reading

ReferenceReading source and market_sessionRead TutorialExchange rates in Next.js and TypeScriptRead GuideIndicative vs executable FX ratesRead
More ComparisonsFixer vs exchangerate.devOpen Exchange Rates vs exchangerate.devCurrencylayer vs exchangerate.devCurrencylayer vs ExchangeRate-API
LearnReading source and market_session in your pipelineIndicative vs executable FX rates: what a rates API actually gives youECB reference rates, explained
Live RatesEUR/USDGBP/USDUSD/JPY