cover-image

JavaScript Memoization

#javascript
#memoization
#cache
#map

Andrew Vo-Nguyen

March 27, 2024

4 min read

Last week at work, we implemented a caching mechanism for Chargebee API calls so that we could avoid API restrictions with Chargebee. This meant that we could memorize the result of an API call in our persistent store. This yielded two advantages:

  1. We could avoid throttling from Chargebee, and as a result, affect our user's experience. If the input of the API call yields the same output multiple times, we can consider these as calls that perhaps shouldn't contribute to our API throttling limit.
  2. We could serve up the data back to our users faster, due to the round trip to our persistent store being quicker than making the API call to Chargebee.

This made me curious about what an implementation would look like in native JavaScript. Turns out it can be quite simple. My naive approach was to first serialize the expensive function and its dependencies (arguments). This is a similar concept to React's useMemo hook. From there, use the serialized string as its unique identifier (or key). Every time the same combination of the function and its arguments is encountered again, we check the cache for the same key and return the result from the cache.

This is what this may look like:

1/**
2 * Runs an expensive function and memoizes the result based on the deps array.
3 * @param {*} fn
4 * @param {*} args
5 * @return {*}
6 */
7function runMemo(fn) {
8  if (typeof fn !== "function") {
9    throw new Error("fn must be a function");
10  }
11
12  const cache = new Map();
13
14  return function (...args) {
15    if (!Array.isArray(args)) {
16      throw new Error("args must be an array");
17    }
18
19    const key = JSON.stringify({
20      fn: fn.toString(),
21      args: args.map((arg) => arg.toString()),
22    });
23
24    const keyExists = cache.has(key);
25
26    if (keyExists) {
27      return cache.get(key);
28    }
29
30    const result = fn(...args);
31    cache.set(key, result);
32
33    return result;
34  };
35}


Now we need to create an expensive function that takes time to complete to test on our runMemo function.

1/**
2 * Runs an expensive function.
3 * @param {*} initialValue
4 * @return {*}
5 */
6function expensiveFn(initialValue) {
7  let sum = initialValue;
8  for (let i = 0; i < 5555555555; i++) {
9    sum += i;
10  }
11  return sum;
12}

To run the expensive function against the cache, we create 3 runs:

  1. Run A: Initial call when the cache is empty. This should force the expensive function to run in its entirety.
  2. Run B: The same function and same arguments from Run A. This should trigger a cache hit, and return the result from the cache, rather than running the expensive function again.
  3. Run C: The same function, with different arguments. This should trigger a cache miss, which means we have to run the expensive function again.
1/**
2 * Runs the example.
3 */
4function run() {
5  const initialValue = 11111;
6  const anotherInitialValue = 22222;
7
8  const fnRunner = runMemo(expensiveFn);
9
10  // runA: Cache miss
11  console.time("runA");
12  const resultA = fnRunner(initialValue);
13  console.timeEnd("runA");
14
15  // runB: Cache hit
16  console.time("runB");
17  const resultB = fnRunner(initialValue);
18  console.timeEnd("runB");
19
20  // runC: Change the initialValue to emulate a cache miss
21  console.time("runC");
22  const resultC = fnRunner(anotherInitialValue);
23  console.timeEnd("runC");
24
25  console.log("resultA", resultA);
26  console.log("resultB", resultB);
27  console.log("resultC", resultC);
28}

These were the results from my local machine:

image-73967f59304e6aeb1e0edac4f51a3ecb348d93bb-828x276-webp

As we can see, the cache behaved as we expected and we saved a good 7 seconds on Run B. This is of course a very basic implementation of a cache. Building upon this, we could make the following improvements:

  • Store metadata relating to the cache to determine how current the data is. This can help in determining the validity of the data.
  • Evict stale data periodically to ensure the data is up to date.
  • Create an interface to interact with the cache manually.
  • Allow a mechanism for the consumer to forcefully bypass the cache and run the expensive function.

The full runnable code can be found here:

https://github.com/andrewvo89/node-memo