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 {*} fn4 * @param {*} args5 * @return {*}6 */7function runMemo(fn) {8 if (typeof fn !== "function") {9 throw new Error("fn must be a function");10 }1112 const cache = new Map();1314 return function (...args) {15 if (!Array.isArray(args)) {16 throw new Error("args must be an array");17 }1819 const key = JSON.stringify({20 fn: fn.toString(),21 args: args.map((arg) => arg.toString()),22 });2324 const keyExists = cache.has(key);2526 if (keyExists) {27 return cache.get(key);28 }2930 const result = fn(...args);31 cache.set(key, result);3233 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 {*} initialValue4 * @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;78 const fnRunner = runMemo(expensiveFn);910 // runA: Cache miss11 console.time("runA");12 const resultA = fnRunner(initialValue);13 console.timeEnd("runA");1415 // runB: Cache hit16 console.time("runB");17 const resultB = fnRunner(initialValue);18 console.timeEnd("runB");1920 // runC: Change the initialValue to emulate a cache miss21 console.time("runC");22 const resultC = fnRunner(anotherInitialValue);23 console.timeEnd("runC");2425 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: