DEV Community

loading...
Cover image for Forever Functional: Memoizing Promises

Forever Functional: Memoizing Promises

OpenReplay Tech Blog
Tech blog for Asayer.io. Quality content by developers for developers interested in JavaScript and related front-end technologies.
Originally published at blog.openreplay.com ・9 min read

by author Federico Kereki

In a complex web application, with several components needing data, odds are that redundant API calls will be made. You may imagine even a simple e-commerce page: the user could want to see an article, then a second one, and then go back to the first. In such a case it's a sure bet that the app would redo its previous API calls, to essentially get the very same information it already had. Caching by the browser may help, for sure, but that also depends on the remote server providing the appropriate headers, and that may not always happen. So, we want to do something in our code that will complement whatever caching is done by the browser, if any, but ensure the best performance for the user in any case.

In the previous article in this series, we showed how we could enhance the performance of your code by memoizing: generating a new version of your functions that use an internal cache to avoid repeating calculations that had been already done. This leads to wondering: can't we do something similar with Promises, and avoid redoing API calls? The answer is a qualified "yes". In this article we'll cover how to do it, a few problems to avoid (including a serious "gotcha!") plus some further enhancements that you can apply to your web app for a better, slicker user experience.

Discarded solutions

Let's begin by thinking of some ways to solve the problem of having repeated API calls... and then discard those solutions!
Note that we'll only consider front-end solutions: modifying the server to allow caching is an obvious idea but it may not be available to you, and may even not be possible at all for some other reasons.
The very first (and most obvious) way to fix this is checking, before doing any kind of call, if the required data is somehow already available - in other words, writing a cache algorithm! This would require adding the cache (possibly using CacheStorage) and modifying your code everywhere to check the cache before actually doing a call... but this isn't a good idea: too many changes to do, too many possibilities for bugs!

You could note that if you are working with React, Vue, or similar frameworks, instead of adding a new cache you might do with the global store instead, with a service layer that checks the store before calling an endpoint to avoid unneeded calls. Of course, you would enlarge the store to also keep previously received values. This solution would also do, and not entail so many code changes, because most would be at the service layer, instead of everywhere throughout your code. But, you'll still need to change your code in non-trivial ways, so the possibility of added errors wouldn't disappear.

What we really want is a solution that requires changing as little code as possible, and not require adding any hand-written special code to test for available data. Also, we'd desire that the solution could be applied selectively, and not an "all-or-nothing" kind of recipe. We want the flexibility to decide where to use this enhancement and where we shouldn't do so. Finally, and thinking in REST terms, we want to apply the fix to GET calls, but not to POST, PUT or DELETE calls; those should always go out (even if made earlier). Our fix has to be very specific!

A (subtly failed) first attempt

Remembering the work we did in our previous article on memoizing, we could think about applying that technique here, too. The code we developed to memoize a function was the following.

const memoize = (fn) => {
  const cache = new Map();
    return (...args) => {
      const strX = JSON.stringify(args);
      if (!cache.has(strX)) {
        cache.set(strX, fn(...args));
      }
      return cache.get(strX);
    };
  };
Enter fullscreen mode Exit fullscreen mode

Our higher-order memoize(...) function takes any function as input, and returns a new function that uses a cache. Whenever you call this new function, it first checks whether if the arguments you are calling the function with, are already in the cache. If so, instead of doing any calculation, it returns the cached value. Otherwise, if the arguments aren't in the cache, the original function is called, its value is stored in the cache, and then it's returned to the caller. The very first time you call a function with any specific set of arguments, it will do its job, but if you repeat the call later, the cached result from the first call will be returned.

Let's go back to our API problem. A common pattern in applications is routing API calls through a single function, getData(), that sets up common parameters (for instance, a token in the headers for authentication) and then uses some library (Axios, SuperAgent, or even plain fetch) to do the call, returning a promise. So, that function would be like the following.

const getData = (urlToCall, someOtherParameters) => {
    // set up headers
    // set up options
    // set up other things
    return doTheCall(urlToCall, headers, options, etc)
}
Enter fullscreen mode Exit fullscreen mode

How can you avoid duplicate calls to an API? Why not apply memoize(...) to the getData(...) function? If we do so, whenever we want to repeat an API call we will get the original promise, without (repeatedly) calling the API endpoint. We can produce a cache-using getCachedData(...) function like this, with a single line of code:

const getCachedData = memoize(getData);
Enter fullscreen mode Exit fullscreen mode

To optimize calls, just substitute getCachedData(...) for getData(...) wherever you want. Of course, there may be some API GETs that you don't want to optimize this way (maybe to some endpoint that always returns freshly updated data) so in that case let the original call to getData(...) be. We won't use memoizing either for other REST calls, as POST, PUT, or DELETE; these have to be done everytime.

This is a very good solution... but with a subtle bug! What are we missing?

A complete solution

The solution we saw above seemingly works well; what's the catch? The problem is what would happen if an API call failed ‑and then we call it a second time? The answer is simple: nothing at all! We already memoized the (rejected) promise, so further calls will return it without trying a second time. In fact, with our memoized promise there will be no way at all to retry the call... so what are we to do?

What we really need is to make our memoizing function aware of possible errors. Should a call fail the promise should be removed from the cache, to allow future new calls to start from zero, and actually call the API. We can make this change fairly easily.

const memoize = (fn) => {
  const cache = new Map();
  return (...args) => {
    const strX = JSON.stringify(args);
    if (!cache.has(strX)) {
      cache.set(
        strX,
        fn(...args).catch((err) => {
          cache.delete(strX);
          return err;
        })
      );
    }
    return cache.get(strX);
  };
};
Enter fullscreen mode Exit fullscreen mode

How is the problem solved? We added a .catch(...) to the promise before adding it to the cache map. With this change, if an API call fails, the rejected promise will be removed from the cache. If you repeat the original call later, the API will be queried again because the results of the first call won't be cached.

The prefetching pattern

Can you do better for the end-user? Maybe your web page cannot be faster, but it can look as if it were! Memoizing promises makes it easy to implement the prefetching pattern: get data into memory before it's actually needed, so when the app actually requires it, it will already be on hand. You can often predict what actions a user will take at a certain moment, so with prefetched data, your page will feel quite snappier.

Implementing data prefetching can be hard, but with memoized promises it's really simple. You just have to do the call for whatever data you think that will be needed, and ignore the results! The key here is that if a future second call is made to get the results you already asked for, the memoized promise will already have them and return instantaneously, with no delay.

To implement prefetching, there's no need to change anything in the code; just to add appropriate calls whenever possible, anticipating future needs. Some obvious examples are calling for the next records if paging through a table, asking for more details on whatever is shown onscreen (assuming the user has some way to see that extra information), and so on. A single caveat: try not to go overboard with prefetching, because browsers have a limit on outgoing connections. If you require too much data, you may find yourself blocked for a while until all prefetching calls come back with their results.

The SWR pattern

A second pattern that will also make your page seem faster and more responsive is the SWR (Stale While Revalidate) pattern. The key idea is that if you ask for some data, and the promise is already cached, you return the current data but at the same time you launch a new API request to refresh the (possibly stale) in-memory data for future use. You could get more fancy by inspecting the staleness of the cached data and using it to decide whether to return data immediately or not, but as a first attempt the described logic will do. Note that this isn't simple caching: with our memoizing solution, an API call would go out only one time, and with SWR API calls will always go out, even if the caller receives results immediately.

You can read more on stale content in RFC-5861.

You may ask: why not make do with browser caching? Browser caches work very well for static contents, but unless the back end provides good values for HTTP headers such as max-age and stale-while-revalidate you really are on your own (also, the browser uses the cache to return data quickly, but doesn't refresh its cache). Of course, ignoring the cache and making requests that will skip it is always possible (so, no caching at all). However, then the user won't see any speed advantage. SWR is an intermediate solution that works well in most cases, and memoizing promises allows for it.

What do we have to change for this new approach?

const memoize = (fn) => {
  const cache = new Map();
  return (...args) => {
    const strX = JSON.stringify(args);
    const result = cache.get(strX);     /* (1) */
    cache.set(                          /* (2) */
      strX,
      fn(...args).catch((err) => {
        cache.delete(strX);
        return err;
      })
    );
    return result !== undefined         /* (3) */
      ? result
      : cache.get(strX);
  };
};
Enter fullscreen mode Exit fullscreen mode

First (1) we try to get an existing promise from the cache; this may succeed, returning the promise, or fail, returning undefined. Then (2) we always set the cache anew, with a new promise, as in the previous section. Finally (3) if we had a promise, we return it, and we return the one just added to the cache otherwise. An important point: to make sure that the call to the server will actually go out, you'll have to force the browser by adding a random parameter (like xyzzy=${Date.now()})at the end of the query string, or equivalently add headers to your request:

  • pragma: no-cache
  • cache-control: no-cache

Either of those methods would get your browser to actually repeat the call, and skip its cache. The "double-whammy" of prefetching plus SWR can help make your code feel much more responsive, and by applying memoization you get all those advantages; good!

Memoizing API calls in frameworks

In our previous article on memoization for functions we saw that popular frameworks like React and Vue implement a simplified, version of it, just caching a single value. Let's consider now what happens concerning the topics we've discussed: memoization for API calls, prefetching, and SWR.

For React, SWR is a library that provides these features, and it's also been ported to Vue as SWRV. React Query is another library that enables React (and React Native) apps to take advantage of all the features we've mentioned.

Installing and using these libraries is straightforward, and they add more features than those we've developed here, so you should give them a try.

Frontend Monitoring

Debugging a web application in production may be challenging and time-consuming. OpenReplay is an Open-source alternative to FullStory, LogRocket and Hotjar. It allows you to monitor and replay everything your users do and shows how your app behaves for every issue.
It’s like having your browser’s inspector open while looking over your user’s shoulder.
OpenReplay is the only open-source alternative currently available.

OpenReplay

Happy debugging, for modern frontend teams - Start monitoring your web app for free.

Summary

In this article, the second in the series about functional programming techniques applied to JavaScript programming, we've seen how to apply memoizing to promises to speed up future retrieval of API data. We also considered a couple of extra techniques for an even sprightlier feel: prefetching, to get data in advance of its being needed, and stale-while-revalidate, to allow providing for cached data, though refreshing it for future usage.

Finally, we discussed some available libraries for React and Vue that simplify taking advantage of the suggestions in the article. Given that you can get a better, more responsive application with little extra work, it would seem that there's no excuse to avoid the optimizations we discussed here!

Discussion (0)