Implementing a Simple Client-Side Cache With Promises

August 26, 2023

When tanstack-query is not an option, you can leverage promises to cache client-side fetch responses and prevent race conditions.

Say you have the following logic:

async function someAsyncFunc() {
  const data = await fetchData();
  // do something with data
}

With every subsequent fetch, a network call is made, which is wasteful if the data remains unchanged. If there's a possibility for it to happen in rapid succession, such as within a useEffect or in an event handler, you also risk race conditions.

The simple, (arguably) intuitive solution to this would be to do something like:

let cachedData<Data | null> = null

async function someAsyncFunc() {
  let data = null
  if (cachedData) {
    data = cachedData
  } else {
    cachedData = await fetchData()
    data = cachedData
  }
  // do something with data
}

The problem

This way the global variable holds the response itself, which works. However, if multiple network calls are triggered in quick succession (e.g. fetch triggering button is clicked several times) there's nothing to prevent all of the requests from going in flight. All of them with the exception of the first one are redundant.

The solution

Instead of storing the response, we can cache the promise itself, like so:

let cachedData<Promise<Data> | null> = null

async function someAsyncFunc() {
  let data = null
  if (cachedData) {
    data = await cachedData
  } else {
    // populate cache with promise, not resolved result
    cachedData = fetchData()
    data = await cachedData
  }
  // do something with data
}

This way, the cache is immediately populated on the first pass without waiting for the promise to be resolved. On subsequent requests, we simply resolve the first request promise and this has no effect on the resolution value.

When network calls depend on variables

Data fetching is often dynamic in that the API call depends on some variable. In such scenarios, the same principle applies. The cache is extended by mapping the variable to its corresponding promise, like so:

const withCache =
  <K, V>(cache: Map<K, Promise<V>>) =>
  (fn: (key: K) => Promise<V>) =>
  (key: K) => {
    const cached = cache.get(key);

    const request = cached
      ? cached
      : fn(key).catch((error) => {
          cache.delete(key);
          // handle error
        });

    if (!cached) {
      cache.set(key, request);
    }
    return request;
  };

Using a contrived example of fetching user data, you can now have a globally available type-safe cache like so:

type Id = string;

// the async fetcher whose promise we're caching
const getUser = async (id: Id) => await fetchUser(id);

// Init cache
// Type of cachedGetUser would  be: `const cachedGetUser: (key: string) => Promise<UserData>`
const cachedGetUser = withCache<Id, UserData>(new Map())(getUser);

// in some async function in your code
const userId = 1; // example
const userData = await cachedGetUser(userId);

There are third-party libraries that solve this problem and encompass a lot more use cases, but this is a simple solution for simple needs.