Home

Published

- 2 min read

Building an Offline-First Currency Converter

img of Building an Offline-First Currency Converter

My Role: Full Stack Software Engineer

Tech Stack: Remix Workbox Upstash Redis Service Workers localStorage

TL;DR

  • Fixer’s free tier is 100 requests/month, so I treated rates as “slow-moving data” and cached aggressively.
  • Works offline after the first visit: server cache + localStorage + a service worker fallback.

Live: multiplecurrencyconverter.fly.dev

Fixer’s free tier is basically unusable for a real product (100 requests/month). But currency rates also don’t change every second. So I stopped thinking “API problem” and started thinking “caching problem”.

The goal was simple:

  • Unlimited users (within reason) without blowing the API limit.
  • Works offline after you’ve opened it once.
  • Doesn’t hard-fail when Fixer (or the network) is down.

The core idea

Cache rates for ~14 hours on the server. That’s 2 calls/day → ~60 calls/month. Everything else is just layers of fallback around that.

Server-side cache (so traffic doesn’t matter)

// app/lib/currency.server.ts
const cacheResponse = await redis.get('currency_response') // TTL: 50,000s
const fallbackCache = await redis.get('currency_response_fallback') // Permanent

if (cacheResponse) {
	return cacheResponse // Cache hit - no API call
}

try {
	const data = await fetch(`https://data.fixer.io/api/latest?access_key=${key}`)

	// Store with 14-hour TTL
	await redis.set('currency_response', data, { ex: 50000 })
	// Store permanent fallback
	await redis.set('currency_response_fallback', data)

	return data
} catch {
	// Graceful degradation
	return fallbackCache ?? hardcodedFallbackRates()
}

Result: traffic can spike and the API call count stays basically flat.

Client-side cache (so offline works)

I persisted the last good payload locally, so if you lose network you still get a usable converter:

// app/components/CurrencyPage.tsx
useEffect(() => {
	localStorage.setItem(
		'currencyData',
		JSON.stringify({
			currencyMap,
			timestamp,
			validatedCurrencies
		})
	)
}, [])

Remix client loader fallback:

// app/routes/convert.$path.tsx
export const clientLoader = async ({ serverLoader }) => {
	try {
		return await serverLoader()
	} catch {
		// Network failed - use localStorage
		const cached = localStorage.getItem('currencyData')
		if (cached) return JSON.parse(cached)
		throw new Error('No data available')
	}
}

Service worker: try network first, but don’t make the UI wait forever:

// app/plain-sw.ts
precacheAndRoute(manifest)

registerRoute(
	({ request }) => request.mode === 'navigate',
	new NetworkFirst({
		cacheName: 'pages',
		networkTimeoutSeconds: 3 // Try network, fallback after 3s
	})
)

Things I learned

  • Rate limits are rarely “API problems”. They’re usually “you didn’t cache” problems.
  • Service worker lifecycle matters. If you don’t manage activation/old caches, you’ll get weird stale behavior that’s hard to debug.
  • Multiple fallbacks are worth it. Redis → localStorage → hardcoded fallback is the difference between “offline-first” and “offline-broken”.

Back to Portfolio Overview