Back to Blog
Building a Smarter Caching System for Faster Web Apps
backenddevelopmentreactfirebase

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.

Related Posts

Event-Driven Architecture

Event-Driven Architecture

Event-Driven Architecture is a way of designing software architecture in which microservices (think it as independent workers that only care about its work only), so when a microservice is given a task, it completes it and emits an event globally to announce its work is “done”.

backenddevelopmentreact+1 more
Read More
Next.JS Data Fetching mistakes & Security vulnerabilities

Next.JS Data Fetching mistakes & Security vulnerabilities

A guide to Next.js data fetching mistakes & security vulnerabilities

frontenddevelopmentnextjs
Read More
JavaScript for Frontend Development: A Beginner's Guide

JavaScript for Frontend Development: A Beginner's Guide

A guide to JavaScript for frontend development for beginners part 1

frontenddevelopmentjavascript
Read More