Skip to main content
Ben Nadel at cf.Objective() 2009 (Minneapolis, MN) with: Kurt Wiersma
Ben Nadel at cf.Objective() 2009 (Minneapolis, MN) with: Kurt Wiersma

Mixing Promises And async / await For Caching Purposes In JavaScript

By on

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 using async / await. Trying to perform recursion strictly with Promise 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:

Interacting with the hero image causes images to get pre-cached in the background.

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

426 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:

  1. 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 🤣

  2. 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

    async readProducts() {

      return new Promise(function (resolve, reject) {

        const apiUrl = `${APP_CONFIG.API_URL}/products`;

        this.http.get(apiURL)
          .toPromise()
          .then(
            res => {
              resolve(res);
            },
            err => {
              reject(err);
            }
          );

      }.bind(this));
    }

app.component.ts

async setUp(): Promise<void> {

    const data = await this.httpService.readProducts();

}

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.

15,663 Comments

@Charles,

In your readProducts() method, you don't actually need the new Promise(). Since you're taking the http Observable and converting it into a Promise, you don't need to then wrap it in another Promise. You could just do:

async readProducts() {

  const apiUrl = `${APP_CONFIG.API_URL}/products`;
  return( this.http.get(apiURL).toPromise() );

}

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 an async 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 the async function, it states:

Async functions always return a promise. If the return value of an async function is not explicitly a promise, it will be implicitly wrapped in a promise.

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 😆

426 Comments

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:

new Promise()

It will make my code look a lot cleaner.

I see that you have also removed this bit:

 .then(
     res => {
         resolve(res);
      },
      err => {
          reject(err);
      }
);

What I wanted, is the data object sent back. Wouldn't, your version send back the promise, itself?

15,663 Comments

@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 a Promise, 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:

await new Promise(
	( resolve, reject ) => { resolve( "foo" ); }
);
// Resolves to "foo".

await new Promise(
	( resolve, reject ) => { resolve( Promise.resolve( "foo" ) ); }
);
// Resolves to "foo".

Notice that the first Promise constructor returns the literal value. And the second one returns another Promise 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 and err from your http Promise will automatically get piped / unwrapped / passed-down to whatever consumes your promise (whether through .then() or await).

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel