Software, Privacy & Freedom

Smart Caching With Promises

· 5 min read

Caching is an important part of many online applications nowadays. Whether you want to minimize request being made to the server or have more responsive UX, caching is your solution. In this post I’m going to explore an interesting way of caching resources requested from the server at client side.

Base code

Let’s create a simple program that does the same thing multiple times asynchronously. This is comparable to a real world program where you would do many API requests simultaneously. In production, you should probably use idempotent calls so caching them makes sense. Here I’m going to use random numbers, so the effects are clear in this demonstration. Ideally the call should be something like await fetch(`/api/item/${i}`) for this to make sense.

const doStuff = async () => {
  // Use something idempotent in a real application.
  // Random numbers are used only for demonstrative purposes.
  return Math.random();
};

// Do stuff 10 times asynchronously.
Promise.all(Array.from({ length: 10 }, doStuff)).then(console.log);

And the output will look something like this:

[
  0.9883598780298299,
  0.8127740353500834,
  0.8211002345912837,
  0.9127730035854036,
  0.32211940453013477,
  0.4421612969239377,
  0.9221032631066164,
  0.3757964358882284,
  0.5118994783576516,
  0.7075099154040383
]

Caching

Now to get started with caching we will use a hash map. There are multiple ways to do caching efficiently, but this is one of the simplest methods. Let’s edit the doStuff function.

/** @type {Map<string, number>} */
const cache = new Map();

const doStuff = async () => {
  // Get the cached value or resolve a new one.
  const value = cache.get("key") ?? Math.random();
  // Cache value for subsequent calls.
  cache.set("key", value);
  return value;
};

And the output will look something like this:

[
  0.20928700568979952,
  0.20928700568979952,
  0.20928700568979952,
  0.20928700568979952,
  0.20928700568979952,
  0.20928700568979952,
  0.20928700568979952,
  0.20928700568979952,
  0.20928700568979952,
  0.20928700568979952
]

The random number is going to be cached, and the same number is returned with subsequent calls. This is exactly what we want out of a caching system, and it works fine (for now, we’ll come back to this), but we can do one better.

Asynchronous caching

One cool thing about JavaScript’s Promises is that you can resolve them multiple times, and they return the same value each time. We can use this fact for our advantage in the caching system. Let’s modify the doStuff function to save Promises instead of the values themselves. Also note the original doStuff function.

const doStuff = async () => {
  // The original `doStuff` function.
  const getValue = async () => {
    return Math.random();
  };

  // Don't await here because we want the promise and not the resolved value.
  const promise = cache.get("key") ?? getValue();
  cache.set("key", promise);
  // Resolve the promise. Could also return it directly.
  return await promise;
};

And the output will look something like this:

[
  0.9700939814065737,
  0.9700939814065737,
  0.9700939814065737,
  0.9700939814065737,
  0.9700939814065737,
  0.9700939814065737,
  0.9700939814065737,
  0.9700939814065737,
  0.9700939814065737,
  0.9700939814065737
]

Now we have come full 180° and used the original implementation of the doStuff function to resolve the value. You might start to see the advantage of this system as the getValue can be any asynchronous function no matter the value it resolves. The caching system can handle any value as it now saves Promises instead of values directly.

This also means that the value does not have to be resolved before it is cached. If we go back to the previous example and make resolving the value take longer a problem occurs.

const doStuff = async () => {
  // Some long running function to resolve the value.
  const getValue = async () => {
    // Wait 1 second.
    await new Promise((resolve) => setTimeout(resolve, 1000));
    return Math.random();
  };

  const value = cache.get("key") ?? (await getValue());
  cache.set("key", value);
  return value;
};

And now the output looks something like this:

[
  0.2825379716481835,
  0.8749512582298473,
  0.5846925983324189,
  0.48317315517440784,
  0.2874661272076098,
  0.20657527761859829,
  0.7968085662267057,
  0.2956729776505944,
  0.8792523599048745,
  0.5479645910102884
]

Uh oh! This is not at all what we want from caching. This happens because we save the resolved value afterward and the other running functions don’t wait for one function to set its value. Let’s try the same with Promise based caching.

const doStuff = async () => {
  const getValue = async () => {
    await new Promise((resolve) => setTimeout(resolve, 1000));
    return Math.random();
  };

  const promise = cache.get("key") ?? getValue();
  cache.set("key", promise);
  return await promise;
};

And the output now looks like this:

[
  0.3730413424070158,
  0.3730413424070158,
  0.3730413424070158,
  0.3730413424070158,
  0.3730413424070158,
  0.3730413424070158,
  0.3730413424070158,
  0.3730413424070158,
  0.3730413424070158,
  0.3730413424070158
]

Perfect! No matter how long it takes to resolve the value, cache is working properly now. This approach should fit pretty much any use case for a simple caching system like this one. For reference the final code will look something like this:

/** @type {Map<string, Promise<number>>} */
const cache = new Map();

const doStuff = async () => {
  // Implement your own `getValue` function.
  // Use something like `await fetch(`/api/item/${i}`)`
  // in a real world application.
  const getValue = async () => Math.random();

  // Should take the key as an argument.
  const promise = cache.get("key") ?? getValue();
  cache.set("key", promise);
  return await promise;
};

Promise.all(Array.from({ length: 10 }, doStuff)).then(console.log);

Conclusion

Due to JavaScript’s dynamic nature you could implement a more generic solution like this:

/** @type {Map<string, number | Promise<number>>} */
const cache = new Map();

/**
 * @param {string} key
 * @param {() => number | Promise<number>} getValue
 */
const cached = (key, getValue) => {
  const value = cache.get(key) ?? getValue();
  cache.set(key, value);
  return value;
};

// Synchronous caching.
console.log(
  Array.from({ length: 10 }, () => cached("sync", () => Math.random())),
);

// Asynchronous caching.
Promise.all(
  Array.from({ length: 10 }, () => cached("async", async () => Math.random())),
).then(console.log);

And it will cache results as expected. There is a lot of room for improvement, so you might want to experiment with this approach. All in all this is a good way to cache things in JavaScript and you will most likely find it useful. Just make sure to diligently test that your caching works as expected.


Have something to add? Don't hesitate to email me.

Written by Human, Not by AI