
Building a Smarter Caching System for Faster Web Apps
Lessons from my internship on designing a high-performance caching system with SWR pattern, localStorage, and Firebase integration.
Building a Smarter Caching System for Faster Web Apps
Ever had a slow website make you want to refresh again and again — hoping it magically loads faster? Yeah, me too. And during my internship, I found out first-hand why that happens, and more importantly, how to fix it.
The Background
It's been a few weeks into my ongoing internship, and I've had the chance to work on some really exciting projects. My first task was to build a text-to-speech pipeline for our web app. But soon after, I was handed something trickier — designing a high-performance caching system to make our web app load faster and reduce redundant Firebase reads.
At first, it sounded simple. Cache some data, fetch it later — right?
Well, it turned out to be a deep rabbit hole into how browsers, React, and Firebase actually manage data behind the scenes.
From Server-Side Rendering to Smart Client Caching
Our original setup relied on server-side rendering (SSR). It worked, but every refresh meant new API calls, higher Firestore reads, and more waiting time for users.
So I implemented a multi-layered caching system — one that could serve data instantly from memory when possible, and fall back to a persistent store (using localStorage) when not.
Here's a small snippet from the client-side caching logic:
export function get(key) {
if (typeof window === "undefined") return null;
try {
const cachedItem = window.localStorage.getItem(getCacheKey(key));
if (!cachedItem) return null;
const item = JSON.parse(cachedItem);
const now = Date.now();
// Check if item has expired
if (now - item.timestamp > item.ttl) {
window.localStorage.removeItem(getCacheKey(key));
return null;
}
return item.value;
} catch (error) {
console.error(`Error getting localStorage cache for key "${key}":`, error);
return null;
}
}The logic is straightforward — data gets stored in localStorage with a timestamp and a TTL (Time-To-Live). Once the TTL expires, the cache clears itself automatically. This ensures that users never see stale data for too long.
Going Deeper: The SWR (Stale-While-Revalidate) Pattern
One concept that completely changed the game was SWR — Stale-While-Revalidate.
In simple terms, it means: Show cached data immediately, then fetch fresh data in the background.
This strategy keeps the UI fast and responsive while ensuring data stays updated. Here's how I implemented it in a custom React hook:
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
const cachedData = cache.get(key);
if (cachedData) setData(cachedData);
setLoading(!cachedData);
try {
const freshData = await fetcher(...args);
if (isMounted) {
setData(freshData);
cache.set(key, freshData, options.ttl);
}
} catch (err) {
if (isMounted) setError(err);
} finally {
if (isMounted) setLoading(false);
}
};
fetchData();
return () => {
isMounted = false;
};
}, [key, fetcher, ...args]);This hook instantly gives your React component cached data (if available), while fetching new data asynchronously — meaning users rarely see a loading spinner.
Centralized Cache Management
To keep track of what's cached, how often it's used, and where it might fail, I built a CacheManager class.
It records hit/miss statistics and can even "pre-warm" caches on startup, making the first load faster.
logStats() {
if (process.env.NODE_ENV !== 'development') return;
const total = this.hits + this.misses;
const hitRate = total > 0 ? ((this.hits / total) * 100).toFixed(2) : 0;
console.groupCollapsed(`[CacheManager] Stats | Hit Rate: ${hitRate}%`);
console.log(` - Hits: ${this.hits}`);
console.log(` - Misses: ${this.misses}`);
console.log(` - Total: ${total}`);
console.groupEnd();
}These logs were invaluable during debugging — watching the cache hit rate improve as I optimized different parts of the app was pretty satisfying.
Real-World Problem: Firebase Local Persistence in Cloud Functions 😵💫
Of course, no project is complete without a "why is this not working?" moment.
In my case, it was Firebase local persistence — which simply refused to work as expected inside Cloud Functions.
Turns out, Cloud Functions don't maintain a persistent runtime environment, so local caching (like IndexedDB or localStorage) doesn't behave the same way as in a browser. I had to rethink my architecture: keep persistence for client-side caching, but rely on Firestore cache layers and temporary in-memory storage for server-side logic.
That detour taught me more about Firebase's behavior under the hood than any tutorial ever could.
The Result
After integrating the new caching system:
- Page load times dropped significantly.
- Firestore reads went down noticeably.
- The UI felt smoother, even on repeat visits.
And the best part? It's all modular — we can now reuse the same caching logic across different parts of the app using the custom React hook.
Key Takeaways
- Caching isn't just an optimization — it's a user experience feature.
- SWR makes your app feel faster without compromising freshness.
- Debugging pain points (like my Firebase persistence issue) are often where the real learning happens.
This whole experience gave me a deeper appreciation for how the smallest architectural decisions — like where you store your data — can make or break performance.
If you're a developer experimenting with React, Firebase, or just trying to make your app faster — start with caching. It's a puzzle worth solving XD.


