Mixing Promises And async / await For Caching Purposes In JavaScript
Now that I've dropped support for IE11 on my blog and have wholly embraced (a work in progress) the constructs of "modern JavaScript", I'm starting to look at where I can add efficiencies. One idea that I had was to pre-cache some photo information in my hero image carousel. At first, my instinct was to await
all the things! However, as I started to think more deeply about the cache, I decided it would be more beneficial to cache Promises, and not the resolved values. Since I'm consuming Promises in two different "formats," so to speak, I thought it might be worth examining.
The magical thing about async
/ await
is that it takes asynchronous code and makes it read like synchronous code. And, I believe that most people would enthusiastically agree that synchronous code is fundamentally easier to reason about since it allows the reader to consume the code in a top-down, natural manner.
Of course, even with async
/ await
you are still dealing with asynchronous code. Ultimately, it's all just syntactic sugar over JavaScript Promises. The sugar may be thick and delicious; but, it's still Promises all the way down.
And, just because we're embracing async
/ await
, it doesn't mean that we have to remove the notion of Promises from our JavaScript. Understanding when to await
a Promise
and when to reference a Promise
directly is part of the learning curve of modern, asynchronous JavaScript.
Getting back to my hero image carousel, on the desktop version of my site it renders a photo and provides Previous and Next buttons for photo navigation. Ideally, by the time the user goes to click on one of these buttons, I'll have already loaded and cached the target information in the background. And, after the user clicks on one of the buttons, I want to then pre-cache even more information so that subsequent interactions with these buttons deal entirely with cached data.
In order to defer loading as much data as possible, I don't pre-cache anything on page-load. Instead, I wait for the user mouseenter
the hero image. This interaction demonstrates possible intent to navigate between photos. And, it's this interaction that triggers the first pre-fetch of photo data.
figure.mouseenter( handleFigureMouseenter );
// ...
function handleFigureMouseenter() {
figure.off( "mouseenter", handleFigureMouseenter );
// Just preload a single image in each direction (Prev and Next).
preCachePrevPhoto( +prevPhoto.data( "rel" ), 1 );
preCacheNextPhoto( +nextPhoto.data( "rel" ), 1 );
}
In my case, since we're "progressively enhancing" a server-side rendered page in ColdFusion, I'm using the DOM (Document Object Model) to deliver pertinent information. In this case, the data-rel
attribute of my Prev/Next button contains the id
of the photo that will be rendered by those interactions.
The 1
in each of these methods calls is telling the caching mechanism how many photos to cache in each "direction" (prev and next). If I were to pass a number greater than 1
, the caching mechanism will start to call itself recursively. Both the prev and next algorithms have the same logic, but deal with different properties. As such, let's just look at the preCacheNextPhoto()
method to get the general idea:
async function preCacheNextPhoto( photoID, wiggleRoom = 3 ) {
// All recursive algorithms needs a base-case that will cause the recursion
// to halt. When we run out of wiggle room, stop recursing.
if ( wiggleRoom <= 0 ) {
return;
}
if ( ! cache[ photoID ] ) {
// CAUTION: The ApiClient returns PROMISES. Our cache is going to be
// filled with Promises, not with raw photo data.
cache[ photoID ] = apiClient.makeRequest({
url: "/index.cfm",
params: {
event: "api.sitePhotos.photo",
index: photoID
}
});
}
try {
// BLOCK AND WAIT for Promise RESOLUTION (ie, the raw photo data).
var response = await cache[ photoID ];
// Warm up the browser cache for this image binary.
new Image().src = response.src;
// Recursively cache the next photo. Note that we are NOT AWAITING this
// recursive call since we are not consuming the response. We also
// don't want any failures within the recursion to clear the cache for
// the current ID.
preCacheNextPhoto( response.nextPhotoID, --wiggleRoom );
} catch ( error ) {
// If something goes wrong, let's assume that the cached Promise is
// problematic (a rejection) and delete it. This way, the next request
// will attempt to fetch and re-cache it.
delete( cache[ photoID ] );
console.warn( "Could not pre-cache next photo." );
console.error( error );
}
}
Notice that when I call apiClient.makeRequest()
on my fetch()
-based API client, I am not using await
. This is because my API client returns a Promise
and I want to cache the Promise
, not the resolution. Let's think about this more deeply for a second.
If I wanted to cache the actual photo data, it means that I wouldn't be able to put anything into the cache until after the underlying AJAX response returned. On a super fast connection, this probably doesn't matter; however, consider what happens if a user hits the "Next" button while the pre-cache operation is still executing asynchronously? Nothing is in the cache yet. And so, another AJAX request would be triggered to fetch that same data. Now, I have multiple requests for the same data running concurrently.
If I cache the Promise
instead of the raw photo data, then I get to populate the cache immediately even while the AJAX request is still pending a response. Now, if the user attempts to navigate again, the algorithm will pull the unresolved Promise
out of the cache and await
it. The user may still end up with concurrent asynchronous operations. But, I now only have one shared Promise
that is mapped to a single AJAX request.
In the case of the mouseenter
pre-caching, I'm only caching one image. However, the "scope" or "depth" of the pre-caching is controlled by the wiggleRoom
argument. If it were greater than 1
, the preCacheNextPhoto()
method calls itself recursively while decrementing its depth argument.
ASIDE: It's amazing how much easier it is to think about recursive
Promise
chains when usingasync
/await
. Trying to perform recursion strictly withPromise
objects will melt your brain!
With this pre-caching in place, the chances are good that the cache will be pre-heated with the next photo's Promise
by the time the user goes to click on the "Next Photo" button. Here's the "Next Photo" handler:
async function showNextPhoto( photoID ) {
if ( ! cache[ photoID ] ) {
cache[ photoID ] = apiClient.makeRequest({
url: "/index.cfm",
params: {
event: "api.sitePhotos.photo",
index: photoID
}
});
}
try {
var response = await cache[ photoID ];
renderPhotoResponse( response );
// While the user is looking at the photo that we just rendered, let's
// pre-cache more photos in the background. This way, by the time the
// user goes to hit the NEXT PHOTO button, we'll (hopefully) have
// already loaded that data and can render it immediately. Note that we
// are NOT AWAITING this response since we don't care when it completes.
preCacheNextPhoto( response.nextPhotoID );
} catch ( error ) {
/// If something goes wrong, let's assume that the cached Promise is
// problematic (a rejection) and delete it. This way, the next request
// will attempt to fetch and re-cache it.
delete( cache[ photoID ] );
console.warn( "Could not show next photo information." );
console.error( error );
}
}
As you can see, the very first thing that this method does is look in the cache. Remember, this cache is full of Promise
instances. Even if the pre-cache operation is still executing asynchronously, the pre-cache Promise
has already been stored. As such, hitting the "Next Photo" button may end up pulling-out a pending Promise; or, it may end up pulling-out a resolved Promise.
That's the magic of Promises! We don't have to care what state they are in. And, when this method then goes to await
the Promise
, it means that the moment the pre-cache operation is completed (assuming it's still pending), our "Next Photo" algorithm will unblock and render the response to the DOM (logic for rendering is not relevant for this demo).
Once the Promise
is resolved and we render the resultant photo response for the user, notice that we are calling preCacheNextPhoto()
again. We are not using await
in this case because we don't care when that asynchronous operation completes. And, we certainly don't want to block the current method while the cache is being populated in the background.
Also notice that, unlike in the mouseenter
pre-caching, we're not passing a 1
into the preCacheNextPhoto()
method. Mousing over the hero image demonstrates a "small intent" - we don't actually know if the user is going to interact with the hero image. As such, we only pre-cache 1 image. However, when the user starts to actively navigate through the photos, that demonstrates a "large intent". As such, we want to increase the scope of our caching algorithm. By omitting the invocation argument, the preCacheNextPhoto()
method will use the argument default of 3
(ie, cache 3 photos into the future).
To see this in action, we can watch the network activity of the browser when I mouse into the hero image and the click the "Next Photo" button:
Notice the following order of events:
When I
mouseenter
into the hero image, I am pre-caching 3 photos (prev, next, and current).When I click on the "Next Photo" button, I pre-cache 3 photos into the future.
When I click on the "Next Photo" button again, only a single
fetch()
request shows up in the network. The algorithm is still pre-caching 3 photos; only, 2 of those 3 photos are already in the cache. As such, the pre-caching algorithm only has to make a request for the delta.
When you start using async
and await
for the first time, it can be easy to fall into the trap of calling await
and blocking on every single Promise
in your application. But, async
and await
are just tools to help make your code easier to read and to reason about - they aren't "the way". Sometimes, dealing with Promises directly is a powerful option. Mixing and matching and use the right construct for the right situation can lead to best JavaScript code.
Want to use code from this post? Check out the license.
Reader Comments
Hi Ben
This is really interesting stuff!
I am just getting into async await in a current Angular project. Usually, I just use RxJs, but I am using the Twitter Lite library that explicitly uses async await, so I am now embracing it, throughout the whole project.
A couple of observations:
I like the way that sometimes you don't use await, if you don't care when the async operation completes. I have just blindly been using await, at every opportunity 🤣
So, now to some code. I have been using the following pattern and I am not entirely sure, whether this is good practice:
http.service.ts
app.component.ts
Is it OK, to use await to return an explicit promise, as I am doing above? I have actually converted Angular's httpClient get method, which normally returns an observable, into a promise.
@Charles,
In your
readProducts()
method, you don't actually need thenew Promise()
. Since you're taking the http Observable and converting it into aPromise
, you don't need to then wrap it in anotherPromise
. You could just do:Any resolution / rejection that comes out of the Observable will automatically propagate to the
Promise
that is being returned from this method.And, as far as returning a
Promise
from anasync
method, there's nothing wrong with that - the Promise-chain will automatically "unwrap" the Promise resolution and pass it down the chain. In fact, if you look at the MDN documentation on theasync
function, it states:So, in reality, all return values from an
async
function are Promises; and, if you don't return a Promise, JavaScript will wrap it up for you. It's Promises all the way down 😆Ben. Thanks for the response. 🙏
I really love async/await
It's great to be able to see the order of execution, which can become obscured with traditional promises.
I still can't quite get my head around how asynchronous tasks can have their response order, guaranteed.
But this is what async/await seems to do?
I keep thinking that there must be some blocking going on, somewhere?
It's good to know that I can remove this part:
It will make my code look a lot cleaner.
I see that you have also removed this bit:
What I wanted, is the data object sent back. Wouldn't, your version send back the promise, itself?
@Charles,
As far as the "order of execution", we have to be careful. If you have multiple parallel asynchronous HTTP requests, there's no guarantee that they will return in order. And actually, in my demo, the pre-caching may return out of order. But, since I'm only ever rendering a single document, I think it should always render the last one that you navigate to. But, there's no doubt that it gets crazy sometimes.
Bottom line, if you put
await
in front aPromise
, then the code will block and wait for that promise to settle. So, at the very least, you have that guarantee.Re: removing the promise, you just have to keep in mind that there's no different between returning a
Promise
and returning a literal value. Both of them result in the same.then()
payload down the chain. You can see this more clearly with some simpe examples:Notice that the first
Promise
constructor returns the literal value. And the second one returns anotherPromise
that resolves to the same literal value. This is because the Promise chain is "unwrapping" the values as they get passed-down.So, in your case, the
res
anderr
from yourhttp
Promise will automatically get piped / unwrapped / passed-down to whatever consumes your promise (whether through.then()
orawait
).Thank you so much for this in depth explanation.
It really is very helpful! 😀👍
Any time - always a pleasure to talk about this stuff.
Nice article! Thanks for sharing this article its really helpful for me.
keep going.
@Payal,
Glad you enjoyed :D
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →