DEV Community

Cover image for Call APIs and caching at client side
Mohan Sebastian
Mohan Sebastian

Posted on

Call APIs and caching at client side

On the previous article, we talked about SSR, pre-fetching, and calling APIs on the server. In this article, we are going to talk about handling APIs on the client side of the app.

As we discussed before, calling APIs on the server has its own advantages. It can help us improve initial loading, SEO, and reduce unnecessary client-side requests.

But not every piece of data should be fetched on the server.

Some parts of the app were strongly connected to user interactions, real-time changes, and user-specific states. For these sections, client-side data fetching gave us much more control and a simpler implementation.

In these cases, forcing server-side fetching was not always the best solution. We needed a way to fetch data when the user actually needed it, update it when something changed, and keep the UI consistent.

This is where client-side fetching and caching became important.

But calling APIs directly from the client comes with its own challenges. Without a proper strategy, you can easily end up with duplicated requests, unnecessary API calls, inconsistent states, and complicated loading logic.

So we needed a solution that could handle caching, revalidation, and synchronization between the UI and the server.

For us, that solution was React Query.

How we did it

For handling client-side data, we used React Query as our main layer for fetching, caching, and revalidating APIs on the client.

We created a global ReactQueryProvider and wrapped the whole application with it. This way, every component could use the same Query Client and share cached data.

We also defined default behaviors like how long data should be considered fresh, when it should refetch, and how long unused data should stay in memory.

The good thing is that most of the caching happens automatically. When we use a query hook, React Query checks if we already have the data in the cache. If the data is still fresh, it returns it instantly. If not, it updates the data in the background.

For example, after adding a product to the cart, we don't manually fetch the cart data again. We simply invalidate the related query, and React Query handles fetching the latest data for us:

This approach helped us keep the UI synced with the server without manually managing loading states, duplicated requests, or complicated cache logic.

How we implemented React Query in the project

The first step was creating a global ReactQueryProvider and wrapping the whole application with it.

We placed it inside our main AppProviders component, next to other global providers like authentication:

The reason we wrapped the whole app with React Query was simple: we wanted every component to have access to the same Query Client and share cached data.

Wrapping the app with ReactQueryProvider doesn't turn everything into client-side rendered content. The pages are already rendered on the server. The provider simply wraps the rendered tree and gives any client components inside access to the React Query context. That's why using providers in layout.tsx is the recommended pattern in Next.js.

Inside the provider, we created a single QueryClient instance and configured the default behavior:

We also enabled React Query DevTools in development to make debugging easier.

One important configuration here is staleTime.

staleTime defines how long fetched data is considered fresh. During this time, React Query returns the cached value and does not send another request.After this time passes, the data becomes stale. React Query can still return the cached data instantly, but it may refresh the data in the background.

Another related option is gcTime (previously known as cacheTime).

These two are easy to confuse:

staleTime: how long the data is considered fresh

gcTime: how long unused cached data stays in memory before being removed

For example, imagine we fetch vendor data:

staleTime: 60s - for 60 seconds, React Query trusts the cached data

after 60 seconds - the data is stale and can be refetched

gcTime: 5 minutes (default in React Query v5) - if no component uses that data for 5 minutes, React Query removes it from memory

So staleTime controls freshness, while gcTime controls memory cleanup. By setting staleTime to 3 minutes, the cached data is considered fresh for 3 minutes. After that, it becomes stale and React Query may fetch fresh data the next time the query is triggered. By setting gcTime to 3 minutes, React Query keeps unused cached data in memory for 3 minutes after the last component stops using it. After that, the cache is removed.

gcTime is set to 5 minutes by default, and in most cases, you probably won't need to change it.

As I mentioned before, gcTime is related to memory usage. Imagine you're caching a very large API response that users rarely revisit. Keeping all that data in memory for several minutes doesn't really give you any benefit and it just consumes memory.

In cases like this, reducing the gcTime can be a better choice.

The Pain of caching Data

We talked a lot about how useful caching is, and one of its biggest advantages is reducing the number of API calls to the server.

But one thing that personally bothered me about it is that it forces you to be much more careful.

Imagine your app has a payment flow. A user makes a purchase using their wallet balance. After the payment succeeds, you need to make sure the wallet data is updated as well. Otherwise, React Query might still serve the old cached balance.

The same thing can happen when the user charges their wallet.

What I'm trying to say is that when working with React Query caching, you need to know exactly which parts of your application can be affected by an API call, and make sure the related queries are updated or invalidated properly.

and some times you might need to invalidate multiple queries just because of calling an API:

So why didn't we cache every response?

In my opinion, not every API is a good candidate for caching.

If an API plays a core role in your application, and its response can be affected by many different backend services or business flows, it might be better not to cache it at all. Calling it again whenever the related component mounts can sometimes be the simpler and safer choice.

On the other hand, maybe having strict caching rules and caching almost every response is actually the better approach.

Besides reducing the number of API calls, it also forces you to understand your application much better. You end up building a much clearer mental model of how different parts of the app work together, what affects each API response, and when each piece of data should be invalidated.

In my opinion, there isn't a single right answer. It all depends on your product and how much complexity you're willing to manage.

CDN Caching - Another Layer of the Story

Until now, we have talked about caching data inside React Query and on the Next.js server.

But there was another cache layer we had to think about: the CDN.

Since most of our pages were rendered on the server and many of them didn't change frequently, there was no reason for every user request to reach our application server.

Instead, we wanted the CDN to serve those pages whenever possible. This reduced requests to our servers, improved response times, and made the application much more scalable.

Writing Cache Policiy

The important thing was that we didn't want every page to have the same cache policy.

For example, the homepage changes much more frequently than the FAQ page.

So instead of hardcoding cache times everywhere, we created cache profiles:

These profiles became the single source of truth for our caching strategy. We didn't use them only for the CDN—we also used the same profiles for Next.js server-side caching. This way, both layers followed the same caching policy, making the behavior predictable and much easier to maintain.

Each profile defines three values:
**
**revalidate:
How long the CDN considers the page fresh.

stale: How long stale content can still be served while the CDN fetches a fresh version in the background.

expire: When the cached content is completely discarded.

This way, every page simply chooses the profile that matches its business requirements:

We could then pass the same cacheLife object to the Next.js configuration and use it as the caching policy for our server-side cache as well.

We also created a small utility called cacheControlFor().

Its job was simply to convert one of those cache profiles into a proper Cache-Control header.

*The interesting part here is max-age=0.
*

We intentionally told browsers not to cache the page themselves.Instead, browsers always validate with the CDN, and the CDN decides whether it already has a fresh version or needs to fetch a new one. That gives us much better control over cache invalidation.

We then assigned these cache profiles to each route inside next.config.ts.

Each route receives its own Cache-Control header based on how frequently its content changes:

But there was one problem...

Imagine we deployed a new version of the application.

The application server is already running the new code.

But the CDN may still have yesterday's HTML and JavaScript cached.

That means users could continue receiving old pages for minutes—or even hours—depending on the cache policy.

Not exactly what we wanted.

Purging the CDN

That's why our deployment wasn't finished after deploying the application.

As the final step of our CI pipeline, we called ArvanCloud's Purge API.

After every successful production deployment, our pipeline automatically cleared the CDN cache for the pages and static assets we cared about. This guaranteed that the very next request would fetch the latest version from the application and warm the CDN again.

Doing this inside the CI pipeline had two advantages:

  • Nobody had to remember to purge the CDN manually.

  • Every deployment automatically became the single source of truth for refreshing cached content.

Wrapping up

So this was our experience with client-side caching using React Query and another layer of caching with our CDN.

The biggest lesson for me was that caching isn't just about making your app faster. Every cache layer comes with its own responsibilities, trade-offs, and debugging challenges. The trick is knowing where to cache, how long to cache, and when to invalidate it.

At the end, our application looked something like this:

             User
               │
               ▼
       React Query Cache
               │
               ▼
      ArvanCloud CDN Cache
               │
               ▼
  Next.js (SSR + Cache Components)
               │
               ▼
        Backend APIs
Enter fullscreen mode Exit fullscreen mode

Each layer has its own job, and together they help reduce unnecessary requests while keeping the application fast and scalable.

In the next article, I'll talk about state management in our project, different state patterns we used, and how we decided where each piece of state should live.

Top comments (0)