Caching is not cheating: Perceived performance wins

Your app feels slow. You did all the optimizations in your code imaginable. You curbed the re-renders, lazy loaded the components, memoized where necessary. And yet, people say: “Hey, it is slow.”

They aren't necessarily saying your UI is janky. What they might mean is: "I clicked on a button and nothing happened for a while."

Here, the culprit could be slow APIs, worsened by network latency or connectivity issues. And when the UI does not respond immediately, the app feels broken, even if it is not.

This is the realm of perceived performance: how fast our app feels, regardless of how fast it is.

Imagine user taps a button and sees a blank screen with a spinner for 1.5 seconds. That is enough for them to feel something is wrong. But maybe the API was just taking its sweet time. Or maybe... we refetched data unnecessarily. In most cases API is not even the problem. the problem lies in how we used this API in our app.

The Hidden Cost of Slow APIs

Assuming the below user journey.
1. User lands on customer customer page
2. CustomerList API returns the response in 2 sec(it needs to aggregate data from different microservices etc)
3. User sees a spinner on this page for 2 sec or more.
4. User clicks on customer item
5. Customer details are loaded
6. User clicks back and then the spinner shows again

If the app always shows a loading spinner even when the user is revisiting the same screen within seconds, it feels broken, even though it is technically working as intended. This constant refetching of data will make our UI feel sluggish.

So, what can we do in this scenario?

Instead of fetching the customer list from scratch every time, we can cache the data once it is loaded the first time. When the user navigates back, we can immediately display the cached list and silently revalidate it in the background if needed. This approach avoids unnecessary spinners, maintains a smooth experience, and helps the app feel far more responsive, even if the underlying APIs are still slow.

Caching to the Rescue

We can reduce this "waiting time" by using simple in-memory caching

Step 1: Basic Fetch

useEffect(() => {
  fetch('/api/user')
    .then(res => res.json())
    .then(setUser);
}, []);

This works, but fetches fresh data every time the component mounts. Even if the data hasn't changed.

Step 2: Add a Manual Cache

useEffect(() => {
  fetchWithCache('user', '/api/user').then(setUser);
}, []);

This shows cached data immediately if available. Huge win for perceived speed

Step 3: Implement Stale-While-Revalidate(SWR)

useEffect(() => {
  const cached = cache.get('user');
  if (cached) setUser(cached);

  fetch('/api/user')
    .then(res => res.json())
    .then(data => {
      cache.set('user', data);
      setUser(data);
    });
}, []);

This pattern shows old data immediately, while updating it silently in the background. To the user, it feels blazing fast.

Scaling This: Write a Custom Hook

While the manual SWR logic is great for one off cases, maintaining this pattern across multiple APIs can quickly get out of hand. Rewriting cache checks, fetch logic, and background updates in every component is repetitive and error-prone.

A better approach is to extract this into a reusable hook, say useCachedFetch, which encapsulates the caching logic and ensures consistency across the app. It can:

  • Accept a cache key and URL

  • Return cached data immediately

  • Trigger background refetch

  • Optionally expose metadata like last updated time or fetch status

This gives us all the perceived performance benefits while keeping components clean and declarative.

const cache = new Map();

const useCachedFetch = (key, url) => {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [lastUpdated, setLastUpdated] = useState(null);

  useEffect(() => {
    const cached = cache.get(key);
    if (cached) {
      setData(cached.data);
      setLastUpdated(cached.timestamp);
    }

    setIsLoading(true);
    fetch(url)
      .then((res) => res.json())
      .then((result) => {
        cache.set(key, { data: result, timestamp: new Date() });
        setData(result);
        setLastUpdated(new Date());
      })
      .finally(() => setIsLoading(false));
  }, [key, url]);

  return { data, isLoading, lastUpdated };
}

Why Do All This Manually?

If we are building a simple app, this approach is great for learning. But caching gets messy fast:

  • How long should data stay cached?

  • What if the data changes?

  • What about pagination?

  • How do we handle failed requests?

  • What if the user goes offline?

That’s a lot of responsibility. And frankly, it has been solved already.

Meet React Query: Smart Caching Without the Pain

Let’s face it, writing caching logic for every API is tiring. It’s doable, but repetitive. And when you want things like retries, background refetching, or pagination? It gets messy real fast.

That’s where React Query steps in. It’s a battle-tested library built to take care of data fetching and caching, the stuff we just spent all that time manually handling.

Here’s what using it looks like:

const { data, isLoading } = useQuery(['user'], () => 
  fetch('/api/user').then(res => res.json())
);

That one line gives you:

  • Automatic caching: React Query remembers your data and reuses it across navigations.

  • Stale-while-revalidate: It shows stale data immediately and refreshes in the background.

  • Background refetching: Triggers updates when the window refocuses or after intervals.

  • Retry on failure: It will retry failed requests automatically with smart backoff logic.

  • Pagination & infinite queries: Built-in tools to handle lists, pages, and scroll.

  • Status indicators: Easily access isLoading, isFetching, isError, isSuccess.

  • Query invalidation: Refetch only the data you need after a mutation.

All of this means your app feels fast and reliable and you do not have to reinvent the wheel.

React Query removes the heavy lifting, but does not get in your way. It is flexible, intuitive, and gives you control when you want it.

Conclusion

Improving perceived performance is often about showing something useful faster, not getting data faster.

We can absolutely build caching ourselves. But if the app is growing or and we want to focus on features, we are better off using react-query. users do not care what tech/library we use. all they care is “Can I quickly get my stuff done” And that’s what matters.

0
Subscribe to my newsletter

Read articles from Koundinya Gavicherla directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Koundinya Gavicherla
Koundinya Gavicherla

Frontend Software Engineer with a passion for unraveling the intricacies of software and systems. Armed with a B.Tech in Mech Engg and an MS in Engg Management from SJSU. My journey spans VFX, tech logistics, and automotive retail, always seeking the harmony between technology and practical application