· By John Kavanagh

Caching Across React, Next.js and Serverless Applications

Abstract image used to represent React, Next.js and Serverless Caching
Image by Arum Visuals.

In Brief

React caching is not one technique. useMemo, browser storage, query caches, HTTP caching, static generation, ISR, serverside rendering, and Redis all solve different problems. Choose the cache based on whether you are avoiding repeated UI work, reusing remote data, controlling freshness, or protecting a serverless function.

Modern web applications rely on fetching data, whether from an API, a database, or another source. The challenge is that every request takes time and consumes resources. Without caching, applications can feel sluggish, and servers can become overwhelmed by unnecessary repeated requests.

Caching helps solve this by temporarily storing data, reducing redundant fetching, and improving performance. In React, we can cache data on the client, whilst Next.js provides builtin serverside caching mechanisms. In a serverless environment, an inmemory cache may be reused by later requests to the same warm instance, but it is neither shared across instances nor guaranteed to survive. Data that must be consistently available therefore needs a shared cache such as Redis.

This is a challenge I've faced recently in a project I've been working on. The gameplay requires very fast API responses or otherwise begins to look broken, whilst the data itself can be massive and complicated. I write about how I resolved that much more in the case study, but it prompted me to write here, too.

In this article, I will explore different caching strategies for React and Next.js applications, including best practices for caching in serverless environments.


Client‑Side Caching in React

Clientside caching is one of the simplest ways to improve performance. If data does not change often, we can store it in memory or local storage instead of refetching it from an API every time.

Using State for Basic Caching

A basic approach is to store fetched data in React state (something we should all be innately familiar with) like this:

const [data, setData] = useState<string | null>(null);

useEffect(() => {
  const cachedData = localStorage.getItem("cachedData");
  if (cachedData) {
    setData(cachedData);
  } else {
    fetch("https://api.example.com/data")
      .then((res) => res.text())
      .then((fetchedData) => {
        setData(fetchedData);
        localStorage.setItem("cachedData", fetchedData);
      });
  }
}, []);

Here, we first check if the data exists in localStorage. If it is not, we then fetch it and store it for future use.

Using React Query for Smarter Caching

For more advanced clientside caching, we can use React Query. The following example uses the TanStack Query version5 object form of useQuery to cache the result and decide when it becomes stale:

import { useQuery } from "@tanstack/react-query";

const fetchData = async () => {
  const res = await fetch("https://api.example.com/data");
  return res.json();
};

const MyComponent = () => {
  const { data, isLoading } = useQuery({ queryKey: ["data"], queryFn: fetchData, staleTime: 60000 });

  if (isLoading) return <p>Loading...</p>;
  return <p>{data}</p>;
};

React Query allows us to hand off cache management, intelligently managing the cache and reducing unnecessary API calls whilst also ensuring fresh data when we need it.


Server‑Side Caching in Next.js

Next.js offers several builtin caching mechanisms, which we can take advantage of to reduce server load and improve application response times.

Static Site Generation (SSG) for Prebuilt Caching

If data does not change frequently, we can prebuild pages with our data at build time using Static Site Generation (SSG). This is great for pages that rarely change, like Author pages on a blog, for example:

export async function getStaticProps() {
  const res = await fetch("https://api.example.com/data");
  const data = await res.json();

  return { props: { data } };
}

Since SSG pages are prebuilt, they do not update unless the site is redeployed. This is perfect for static content but impractical for frequently changing data.

Incremental Static Regeneration (ISR)

ISR builds on SSG by allowing requests to trigger regeneration after a freshness interval has elapsed. The page can keep serving its cached output whilst that regeneration runs:

export async function getStaticProps() {
  const res = await fetch("https://api.example.com/data");
  const data = await res.json();

  return { props: { data }, revalidate: 30 };
}

Here, revalidate: 30 makes the page eligible for regeneration after thirty seconds. A subsequent request triggers the work; it is not a timer that rebuilds the page every thirty seconds. That request can receive the previous output whilst the replacement is generated.

Server‑Side Rendering (SSR) with Built‑in Caching

ServerSide Rendering (SSR) can fetch data for each request that reaches the page renderer. For public output that is identical for every visitor, shared HTTP caching can reduce how often that renderer runs. Do not use the following public cache policy for personalised or protected responses:

export async function getServerSideProps({ res }) {
  res.setHeader("Cache-Control", "public, s-maxage=60, stale-while-revalidate=30");

  const response = await fetch("https://api.example.com/data");
  const data = await response.json();

  return { props: { data } };
}

These headers cache the rendered page response in a shared HTTP cache, rather than caching the upstream API response separately. s-maxage=60 gives it sixty seconds of freshness; stale-while-revalidate=30 allows the cache to serve stale output for a further thirty seconds whilst revalidating, where the hosting platform supports that behaviour.


Caching in Serverless Environments

Why In‑Memory Storage is Only Opportunistic in Serverless Functions

Serverless platforms such as Vercel and Netlify may reuse a warm function instance, so modulelevel objects can survive for later calls handled by that instance. The platform can also create or retire instances at any time, which means that:

  • Variables stored in memory can be reused by requests that reach the same warm instance.
  • If a function is idle, it is shut down, clearing any data you may have cached.
  • There is no guarantee the same function instance will handle the next request.

For example, this inmemory cache can produce a hit on a reused instance, but cannot provide a shared or durable serverless cache:

let cache = {};

export default async function handler(req, res) {
  if (cache.data) {
    return res.json(cache.data);
  }

  const response = await fetch("https://api.example.com/data");
  cache.data = await response.json();
  res.json(cache.data);
}

A new instance starts with an empty cache object, whilst a warm instance may reuse its copy. Under concurrency there can be several independent copies, so this is a useful opportunistic optimisation only when misses and inconsistent contents are acceptable.

Using Redis for Shared Serverless Caching

When cached data must be shared across instances, we need a shared external cache with the required availability and durability configuration. This is where Redis can fit.

What is Redis?

Redis (Remote Dictionary Server) is an inmemory data store designed for lowlatency operations, with optional persistence modes. Keeping the working data in memory avoids much diskbound query work, but applicationobserved latency still includes connection handling, serialisation, network distance and command choice. Redis is therefore useful for caching API responses, session data and frequently accessed computations when it is deployed close to the functions that call it.

A Redis cache hit can be faster than repeating an origin API or database query when the saved work outweighs the Redis round trip. That depends on deployment topology because:

  • Data is stored in memory
    , avoiding disk reads and complex query processing.
  • Latency is minimal
    inside a wellprovisioned Redis server, but the client still pays network and system latency.
  • Operations are optimised for speed
    , with commands like GET and SET executing in constant time (O(1)).

Using Redis to Cache API Responses

For public data that is the same for every caller, we can cache an API response under a shared key. The following example assumes that publicdata boundary. Protected data still needs caller authorisation, with cache keys scoped to the relevant user, tenant and permissions:

import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL);

export default async function handler(req, res) {
  // Check Redis cache first
  const cachedData = await redis.get("apiData");
  if (cachedData) {
    return res.json(JSON.parse(cachedData));  // Return cached response
  }

  // Fetch fresh data from the API
  const response = await fetch("https://api.example.com/data");
  const data = await response.json();

  // Store in Redis with a 60-second expiration
  await redis.set("apiData", JSON.stringify(data), "EX", 60);

  res.json(data);
}

Now, if multiple users request the same data within 60 seconds, instances can share the Redis value rather than each waiting for the origin API. The response time is the Redis client round trip for the deployed topology, not an intrinsic zerocost lookup.

When is Redis Faster than the Origin?

An origin API may include authentication, network hops and database work. Redis command processing can be submillisecond, but the application must also pay its network round trip. Measure both paths from the deployed function; a distant Redis service can be slower than a nearby origin. A cache hit is beneficial when it removes enough work to beat that complete path because:

  • A cache hit can avoid a repeated upstream API call, but it must not skip the caller authorisation required to access the data.
  • It avoids making database queries
    for each user.
  • It can reduce network and origin latency
    when Redis is colocated with the function and replaces a slower remote request.

By integrating Redis into a serverless architecture, we can significantly reduce response times and minimise redundant API calls, making our applications much more scalable.


Wrapping Up

Caching is crucial for optimising React and Next.js applications. Clientside caching improves performance for repeated API calls, whilst Next.js offers builtin caching mechanisms like ISR and HTTP cache headers. In serverless environments, inmemory state may be reused within a warm instance, but shared cache correctness needs an external service such as Redis and latency must be measured in the deployed topology.

Key Takeaways

  • Clientside caching

    (React Query, localStorage) reduces API calls.
  • Next.js caching

    (SSG, ISR, SSR) minimises server requests and improves load times.
  • Serverless functions can

    reuse inmemory state on a warm instance, but cannot treat it as shared or durable.
  • Redis provides a shared cache; persistence is optional and its durability depends on the chosen configuration.

Using the right caching strategy makes a huge difference in performance and scalability. Whether we are optimising API calls in React, caching pages in Next.js, or handling data in a serverless environment, a wellplanned approach keeps our applications fast and responsive.


Untangling a delivery problem?

Send the symptoms, constraints, and affected routes. I'll help identify whether the issue sits in the application, platform, content model, deployment path, or search surface.