JavaScript
Article

Preloading Images in Parallel with Promises

By Hugo Giraudel

The topic of this article is actually quite specific. Recently, I faced a situation where I needed to preload a lot of images in parallel. With the given constraints, it ended up being more challenging than first expected, and I certainly learnt a lot along the journey. But first, let me describe the situation shortly before getting started.

Let’s say we have a few “decks” on the page. Broadly speaking, a deck is a collection of images. We want to preload the images of each deck, and be able to know when a deck is done loading all its images. At this point, we are free to run any piece of code we want, such as adding a class to the deck, running an image sequence, logging something, whatever…

At first, it sounds quite easy. It even sounds very easy. Although perhaps you, like I did, overlooked a detail: we want all decks to load in parallel, not in sequence. In other words, we do not want to load all images from deck 1, then all images from deck 2, then all images from deck 3, and so on.

Indeed, it is not ideal because we end up having decks waiting for previous ones to finish. So in a scenario where the first deck has dozens of images, and the second one has only one or two, we would have to wait for the first deck to be fully loaded before being ready for deck 2. Ugh, not great. Surely we can do better!

So the idea is to load all the decks in parallel so that when a deck is fully loaded, we don’t have to wait for the others. To do so, the rough gist is to load the first image of all the decks, then the second of all the decks, and so on until all the images have been preloaded.

Alright, let’s start with creating some markup so we all agree on what’s going on.
By the way, in this article I will assume that you are familiar with the idea of promises. If it is not the case, I recommend this little reading.

The Markup

Markup-wise, a deck is nothing but an element such as a div, with a deck class so we can target it, and a data-images attribute containing an array of image URLs (as JSON).

<div class="deck" data-images='["...", "...", "..."]'>...</div>
<div class="deck" data-images='["...", "..."]'>...</div>
<div class="deck" data-images='["...", "...", "...", "..."]'>...</div>

Preparing the Ground

On the JavaScript side, this is —unsurprisingly— a bit more complex. We will build two different things: a deck class (please put this between very large quotation marks and do not nitpick over the term), and a preloader tool.

Because the preloader has to be aware of all the images from all the decks in order to load them in a specific order, it needs to be shared across all the decks. A deck cannot have its own preloader, else we end up with the initial problem: code is executed sequencially, which is not what we want.

So we need a preloader that is being passed to each deck. The latter adds its images to the queue of the preloader, and once all the decks have added their items to the queue, the preloader can start preloading.

The execution code snippet would be:

// Instantiate a preloader
var ip = new ImagePreloader();
// Grab all decks from the DOM
var decks = document.querySelectorAll('.deck');

// Iterate over them and instantiate a new deck for each of them, passing the
// preloader to each of them so that the deck can add its images to the queue
Array.prototype.slice.call(decks).forEach(function (deck) {
  new Deck(deck, ip);
});

// Once all decks have added their items to the queue, preload everything
ip.preload();

I hope it makes sense, so far!

Building the Deck

Depending on what you want to do with the deck, the “class” can be quite long. For our scenario, the only thing we do is add a loaded class to the node when its images have done loading.

The Deck function has not much to do:

  1. Loading the data (from the data-images attribute)
  2. Appending the data to the end of the preloader’s queue
  3. Telling the preloader what to do when the data has been preloaded
var Deck = function (node, preloader) {
  // We get and parse the data from the `data-images` attribute
  var data = JSON.parse(node.getAttribute('data-images'));

  // We call the `queue` method from the preloader, passing it the data and a
  // callback function
  preloader.queue(data, function () {
    node.classList.add('loaded');
  });
};

It’s going very well so far, isn’t it? The only thing left is the preloader, although it is also the most complex piece of code from this article.

Building the Preloader

We already know that our preloader needs a queue method to add a collection of images to the queue and a preload method to launch the preloading. It will also need a helper function to preload an image, called preloadImage. Let’s start with that:

var ImagePreloader = function () { ... };
ImagePreloader.prototype.queue = function () { ... }
ImagePreloader.prototype.preloadImage = function () { ... }
ImagePreloader.prototype.preload = function () { ... }

The preloader needs an internal queue property to hold the decks it has to preload, as well as their respective callback.

var ImagePreloader = function () {
  this.items = [];
}

items is an array of objects, where each object has two keys:

  • collection containing the array of image URLs to preload,
  • callback containing the function to execute when the deck is fully loaded.

Knowing this, we can write the queue method.

// Empty function in case no callback is being specified
function noop() {}

ImagePreloader.prototype.queue = function (array, callback) {
  this.items.push({
    collection: array,
    // If no callback, we push a no-op (empty) function
    callback: callback || noop
  });
};

Alright. At this point, each deck can append its images to the queue. We now have to build the preload method that will take care of actually preloading the images. But before jumping on the code, let’s take a step back to understand what we need to do.

The idea is not to preload all the images from each deck, one after the other. The idea is to preload the first image of each deck, then the second, then the third, and so on.

Preloading an image means creating a new image from JavaScript (using new Image()) and apply a src to it. This will intimate the browser to load the source asynchronously. Because of this asynchronous process, we need to register a promise, that will resolve when the resource has been downloaded by the browser.

Basically, we will replace each image URL from our arrays with a promise that will resolve when the given image has been loaded by the browser. At this point, we will be able to use Promise.all(..) to have an ultimate promise that resolves when all the promises from the array have resolved. And this, for each deck.

Let’s start with the preloadImage method:

ImagePreloader.prototype.preloadImage = function (path) {
  return new Promise(function (resolve, reject) {
    // Create a new image from JavaScript
    var image = new Image();
    // Bind an event listener on the load to call the `resolve` function
    image.onload  = resolve;
    // If the image fails to be downloaded, we don't want the whole system
    // to collapse so we `resolve` instead of `reject`, even on error
    image.onerror = resolve;
    // Apply the path as `src` to the image so that the browser fetches it
    image.src = path;
  });
};

And now, the preload method. It does two things (and thus could possibly be split in two different functions, but that’s outside of the scope for this article):

  1. It replaces all the image URLs with promises in a specific order (first image from each deck, then second, then third…)
  2. For each deck, it registers a promise that calls the callback from the deck when all the promises from the deck have resolved (!)
ImagePreloader.prototype.preload = function () {
  // Promises are not supported, let's leave
  if (!('Promise' in window)) {
    return;
  }

  // Get the length of the biggest deck
  var max = Math.max.apply(Math, this.items.map(function (el) {
    return el.collection.length;
  }));

  // Loop from 0 to the length of the largest deck
  for (var i = 0; i < max; i++) {
    // Iterate over the decks
    this.items.forEach(function (item) {
      // If the deck is over yet, do nothing, else replace the image at
      // current index (i) with a promise that will resolve when the image
      // gets downloaded by the browser.
      if (typeof item.collection[i] !== 'undefined') {
        item.collection[i] = this.preloadImage(item.collection[i])
      }
    }, this);
  }

  // Iterate over the decks
  this.items.forEach(function (item, index) {
    // When all images from the deck have been fetched by the browser
    Promise.all(item.collection)
      // Execute the callback
      .then(function () { item.callback() })
      .catch(function (err) { console.log(err) });
  });
};

That’s it! Not that complex after all, do you agree?

Pushing Things Further

The code is working great although using a callback to tell the preloader what to do when a deck is loaded is not very elegant. You might want to use a Promise rather than a callback, especially since we used Promises all along!

I was not sure how to tackle this so I have to agree I asked my friend Valérian Galliat to help me on this.

What we use here is a deferred promise. Deferred promises are not part of the native Promise API so we need to polyfill it; thankfully it is only a matter of a couple of lines. Basically a deferred promise is a promise than you can resolve later on.

Applying this to our code, it would change very little things. The .queue(..) method first:

ImagePreloader.prototype.queue = function (array) {
  var d = defer();
 
  this.items.push({
    collection: array,
    deferred: d
  });
    
  return d.promise;
};

The resolution in the .preload(..) method:

this.items.forEach(function (item) {
  Promise.all(item.collection)
    .then(function () { item.deferred.resolve() })
    .catch(console.log.bind(console));
  });

And finally the way we add our data to the queue of course!

preloader.queue(data)
  .then(function () {
    node.classList.add('loaded');
  })
  .catch(console.error.bind(console));

And we’re done!

In case you want to see the code in action, take a look at the demo below:

See the Pen QjjGaL by SitePoint (@SitePoint) on CodePen.

Conclusions

There you go folks. In about 70 lines of JavaScript, we managed to asynchronously load images from different collections in parallel, and execute some code when a collection is done loading.

From there, we can do a lot of things. In my case, the point was to run these images as a quick loop sequence (gif-style) when clicking a button. So I disabled the button during the loading, and enabled it back once a deck was done preloading all its images. Thanks to this, the first loop run is going seamlessly since the browser has already cached all the images.

I hope you like it! You can have a look at the code on GitHub or you can play with it directly on CodePen.

  • Rudi Strydom

    Tnx for the walk through Hugo, quite interesting. I have 2 questions please:
    1. Do you guys compile back down to older standards to support older browsers?
    2. I am a bit confused, if it loads 1 image from each deck. Then logically deck 2 “3 images” would load first, but deck 3 with “10 images” loads before it, what could the reason for that be?

    • http://hugogiraudel.com/ Hugo Giraudel

      Hey.
      1. Babel can be used for this. :)
      2. The demo runs in sequence, not parallel so the first deck gets loaded first, then second, then third.

      • http://careersreport.com lisa.bumgarn

        I want to share amazing #online freelancing opportunity… 3-5 hrs of work /day… Paycheck every week…Extra bonus for job well done…Payment of $6k to$9k a month… Merely several hrs of free time, a computer, basic knowing of# internet and! trusted internet-connection is what is required…Get more info on my page

      • Andrew Charnley

        Sequencing could have been accomplished by reducing three Promises (one for each deck) with each containing the image loading Promises. That way they’d be no need for another library.

Recommended

Learn Coding Online
Learn Web Development

Start learning web development and design for free with SitePoint Premium!

Get the latest in JavaScript, once a week, for free.