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:
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/**
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:
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:
The full runnable code can be found here: