Memoize JavaScript Promises for Performance

Federico Kereki
Globant
Published in
9 min readJul 23, 2020

--

Enhance the performance of web applications by memoizing, a functional programming technique that lets you avoid wasteful redundant calls.

When you have a complex web application, with many interconnected parts, it may very well happen that redundant API calls are made for no good reason, slowing things down and producing a bad user experience — how can that be solved?

The problem

In our particular case, our web application was dashboard-style, with many tabs and selectable options, and the bad behavior was this: when the user selected a tab, several API calls would go out to get all the data needed for the page, but if the user moved away to a different tab and later came back to the original one, the very same API calls would go out again. Most of the data handled by the application were basically constant (i.e., not real time) and not expected to change during the session; there were only a (very few) API calls that really needed to be re-sent, while for the majority re-using the previous response would be perfectly fine.

In this article we’ll first consider some possible ways of solving this problem (that were discarded); then we’ll move on to a functional programming technique that provided the best solution, and we’ll end by considering a “gotcha” (with its solution, of course!) that could have had a serious impact if not caught. Applying the technique shown in this article can help you speed up any web application, with very small code changes; a good investment!

Some (discarded) solutions

Let’s start by considering some possible solutions to the problem of repeated calls — and see why we did otherwise.

The first (pretty obvious!) way of avoiding redoing calls would be to modify the server to enable caching. This is the most standard way of solving the problem, but in our situation we weren’t the owners of the back end, and thus it wasn’t possible to modify caching headers. So, given this restriction, we had to go for a “front-end-only” solution.

Another way of avoiding repeated calls would be checking, before doing anything else, if the required data is already available because of an earlier call, and if so, skip the call — a hand-made cache, if you will. This would work, but would entail adding some data structure to store data about calls (or maybe using CacheStorage), checking if a needed call was already made before repeating it, updating the structure properly for the sake of future callers after a successful call… too much code needing change, too much work, and too error prone!

As an alternative to the cache described above, and given that we were already working with a global store (we were using Svelte, but the same would apply to React, Vue, and many others) instead of checking if the API call had been done, look if the needed data is already available — but in effect that would also have required code changes, and the possibility of errors would still be present.

Ideally, we want a solution that requires changing little code, with no hand-written added tests for data. Also, we should be able to apply the solution to specific endpoints — so it wouldn’t be an “all-or-nothing” remedy— giving us flexibility as to its application.

To see how we finally solved this problem with API calls, we’ll now take an aside and talk about Fibonacci numbers and how to calculate them — and yes, I promise it will eventually make sense!

An aside: Fibonacci numbers (?!)

Fibonacci numbers (0, 1, 1, 2, 3, 5, 8, 13, etc.) are well known, at least because they are often used for teaching recursion, for programming challenges, or for estimating user stories’ points in agile methodologies. Their standard recursive definition is: the first two terms Fib(0) and Fib(1) are respectively 0 and 1, and successive terms in the series are the sum of the two previous ones, or in other words Fib(n)=Fib(n-2)+Fib(n-1) for n>1.

Implementing this in JavaScript is quite straightforward, as we can see below.

let fib = (n) => {
if (n === 0) {
return 0;
} else if (n === 1) {
return 1;
} else {
return fib(n - 2) + fib(n - 1);
}
};

This code is clear, simple, and correct… but slow! How come? I did some experiments for higher values of n, and the following chart (taken from my Mastering JavaScript Functional Programming book for Packt Publishing) shows that the required time grows exponentially, while all it should require are a few sums… what’s happening?

Chart showing exponential growth in time for Fibonacci calculations
Calculating the n-th element of the Fibonacci series requires exponentially growing time

To understand the problem, let’s see what calculations are needed for a simple example, fib(6). The following image (also from the forementioned book) traces all the required calls.

Chart showing the many repeated calls involved in a simple calculation
A simple call to calculate fib(6) involves many repeated recursive calls

Aha! The problems is obvious now: there are many repeated calls. For instance, calculating fib(6) requires calculating bothfib(4) and fib(5) — but calculating the latter again requires calculating fib(4). The redundancy grows worse with further calls: just note how often we re-calculate fib(2) or fib(1) for example. We are wasting time redoing work that we did before; how can we solve this?

Memoizing: a general solution

Memoizing is a general functional programming technique, that can be applied to any pure function — that is, a function without side effects, and that will return the same results if given the same arguments. (Usually you wouldn’t consider an I/O related function to be pure — it obviously has side effects — but since we are saying that the results won’t change from call to call, we may relent in our case.)

When you memoize a function, you produce a new function that before doing any calculations, will check an internal cache to see if the calculation was already than before, and if so, will return the cached value without any further ado. In case the value hadn’t been calculated earlier, the function will just do its thing, but before returning its final result it will add it to the cache for future reference.

We want to write a memoize() higher order function that will take any generic function and produce a new caching version, which can be used instead of the original, with the same results but enhanced performance. There are several available solutions for this (such as the well-named fast-memoize — and check out this article if you are curious how this function was written; it’s well worth the read) but we’ll see later why we had to write our own. In any case, it’s not so hard to come up with that function; the following is what we wrote.

const memoize = (fn) => {
let cache = {};
return (...args) => {
let strX = JSON.stringify(args);
return strX in cache
? cache[strX]
: (cache[strX] = fn(...args));

};
};

How does this work? We are using a cache object to save calculated values; a map would have done as well. We use JSON.stringify() to get a string out of the function’s arguments, and before doing anything we look into the cache: if we find the value in there, we just return it, and if it’s not there, we call the original function and store the result in the cache. We can see it in action straightaway.

fib = memoize(fib);
console.log(fib(100)); // ultra fast!

We replaced the original fib function with the memoized one, and now a call such as fib(100) is practically instantaneous! How will memoizing work for us? Let’s see how we applied it… and the problem we missed!

A first solution — with a catch!

So, we can now start thinking about a solution to our performance problem. In the application, all API calls to get something from the server are done through promises; a global makeCall() function rounds up the needed parameters for the call, and uses Axios to do the call and return a promise. So, in terms of the actual code, all API calls look like the following fragment.

const getSomething= (parameters) => {
// ...set up options object (headers, etc.)
return makeCall(urlForSomething, options);
}

What was our idea to avoid duplicate calls to an API? We memoized the function that makes the actual call, so if called again it would return the same promise as earlier, instead of (again) calling the back end. The function that would use Axios to do the actual call was renamed to originalMakeCall, and the makeCall name was assigned to the memoized function instead.

const originalMakeCall= (url, options) => { 

return axios.get(…);
}
const makeCall= memoize(originalMakeCall);

This works great, and we didn’t have to touch the rest of the application! Whenever makeCall() is called, thanks to memoizing, if you call the same API with the same arguments again and again, only one call (the first one!) goes out to the server; the following ones return the cached value, that is to say, the promise.

Of course we knew that for a few API endpoints we shouldn’t use memoizing, but that was simple to fix: we just changed the needed calls so instead of calling makeCall() they would call originalMakeCall() — simple! And, of course, we didn’t use the memoized call for anything but GET calls — all POST, PUT, DELETE, and other calls would remain as earlier, un-memoized.

However, we had missed something…

A complete solution

What did we forget? The solution that I described above works perfectly, but there’s a catch: what happens if an API call fails, and we attempt the call again? The answer: nothing at all! The memoized makeCall() function will keep returning the (rejected) original promise, so there will be no way to retry the call. What can we do?

The key to the solution is to make memoize() aware of possible errors, and if a promise fails, to remove it from the cache so future attempts will start anew. If we had been working with a standard memoizing function this change would have been nigh impossible, but in our case it required adding just a couple of lines.

const promiseMemoize = (fn) => {
let cache = {}
return (...args) => {
let strX = JSON.stringify(args);
return strX in cache ? cache[strX]
: (cache[strX] = fn(...args).catch((x) => {
delete cache[strX];
return x;
}
))
}
}

Note the changed lines: we added a .catch() to the promise, so upon failure it will remove the corresponding entry from the cache. We also renamed the function to promiseMemoize() to make it clear that it was a different thing, and not your standard everyday memoizing code. With this new function, if a call fails and you retry it, the new attempt will not find the promise in the cache, and the API will be queried again: problem solved!

After including this change (and being careful where and when we used the memoized calls) we noticed a clear speed enhancement when the user changed from a tab to another. We were also able to do something else: we could do advance calls to APIs asking for data that the user would require for a different tab, so those responses would be in the cache and future calls would have an instantaneous answer — another good result for the user!

Summary

We have described a performance problem related to redundant, repeated calls, which was solved by memoizing, a functional programming technique. Instead of a generic implementation, we had to develop our own solution in order to deal with API failures and to be able to retry the call in the future. The optimization also allowed us to do API calls in advance, so information that would potentially be required at a later time would already be available, for a faster response time.

The modification shown here can be applied in most web applications, and it provides a simple way to enhance performance — a nice win!

--

--

Federico Kereki
Globant

Computer Systems Engineer, MSc in Education, Subject Matter Expert at Globant