SoundCloud for Developers

Discover, connect and build

Backstage Blog February 20th, 2014 JavaScript Smooth image loading by upscaling By Nick Fisher

The site soundcloud.com is a single-page application that displays a multitude of users’ images. At SoundCloud, we use a technique to make the loading of an image appear smooth and fast. When displaying an image on screen, we want it to display to the user as fast as possible. The images display in multiple locations from a tiny avatar on a waveform to a large profile image. For this reason, we create each image in several sizes. If you are using Gravatar, this technique also applies because you can fetch arbitrarily-sized images by passing the desired size in a query parameter (?s=).

avatar-tiny avatar-small avatar-medium avatar-large

The technique uses the browser’s cache of previously loaded images. When displaying a large avatar image, first display a smaller version that is stretched out to full size. When the larger image has loaded, it fades in over the top of the smaller version.

The HTML looks like this:

<img class="placeholder" src="small.jpg" width="200" height="200">
<img class="fullImage" src="large.jpg" width="200" height="200">

The CSS looks like this:

.fullImage {
  transition: opacity 0.2s linear;
}

For the sake of brevity, the positioning code is not included in the preceding snippet. However, the images should lie atop one another.

Finally, the JavaScript code looks like this:

var fullImage   = $('.fullImage'),
    placeholder = $('.placeholder');

fullImage
  .css('opacity', 0)
  .on('load', function () {
    this.style.opacity = 1;
    setTimeout(placeholder.remove.bind(placeholder), 500);
  });

Thus far, it’s not too complicated, and it gives a nice effect to the loading of images.

But there’s a problem: we don’t want to make a request to get the small image just to display it for a few milliseconds. The overhead of making HTTP requests means that loading the larger image will usually not take significantly longer than the small one. Therefore, it only makes sense to use this technique if a smaller image has already been loaded during a particular session and thus served from the browser’s cache. How do we know what images are in cache? Each time an avatar is loaded, we need to keep track of that. However over time, there could be many thousands of avatars loaded within one session, so it needs to be memory efficient. Instead of tracking the full URLs of loaded images, we extract the minimum amount of information to identify a image, and use a bitmask to store what sizes have been loaded:

// a simple map object, { identifier => loaded sizes }
var loadedImages = {},

  // Let's assume a basic url structure like this:
  // "http://somesite.com/{identifier}-{size}.jpg"
  imageRegex = /\/(\w+)-(\w+)\.jpg$/,

  // a list of the available sizes.
  // format is [pixel size, filename representation]
  sizes = [
    [ 20, "tiny"  ],
    [ 40, "small" ],
    [100, "medium"],
    [200, "large" ]
  ];

// extract the identifier and size.
function storeInfo(url) {
  var parts = imageRegex.exec(url),
      id    = parts[1]
      size  = parts[2],
      index;

  // find the index which contains this size
  sizes.some(function (info, index) {
    if (info[1] === size) {
      loadedImages[id] |= 1 << index;
      return true;
    }
  });
}

// once the image has loaded, then store it into the map
$('.fullImage').load(function () {
  storeInfo(this.src);
});

When the image loads, we extract the important parts from the URL: namely, the identifier and the size modifier. Each size is mapped to a number—its index in the sizes array—and the appropriate bit is turned on in the loadedImages map. The code on line 27 does this conversion and bit manipulation; 1 << index is essentially the same as Math.pow(2, index). By storing only a single number in the object, we save quite a bit of memory. A single-number object could contain many different flags. For example, assume there are four different sizes and 10,000 images in the following map:

asBools = {
  a: [true, true, false, true],
  b: [false, true, false, false],
  // etc...
};

asInts = {
  a: 11,  // 2^0 + 2^1 + 2^3 = 1 + 2 + 8
  b: 2,   // 2^1
  // etc...
}

The memory footprints of these objects differ by 30%: 1,372,432 bytes for the booleans, and 1,052,384 for the integers. The key names consume the largest portion of these objects’ memory. For this reason, it is important to compress the key names as much as possible. Numeric keys are stored particularly efficiently by the V8 JavaScript engine found in Chrome and Safari.

We now have a map that shows us what images have been loaded during this session, and you can use that information to choose a placeholder:

// find the largest image smaller than the requested one
function getPlaceholder(fullUrl) {
  var parts = imageRegex.exec(fullUrl),
      id = parts[1],
      targetSize = parts[2],
      targetIndex;

  sizes.some(function (info, index) {
    if (info[1] < targetSize) {
      targetIndex = index;
      return true;
    }
  });

  while (targetIndex >= 0) {
    if (loadedImages[id] & 1 << targetIndex) {
      return fullUrl.replace(
        /\w+\.jpg$/,
        sizes[targetIndex][1] + '.jpg'
      );
    }
    --targetIndex;
  }
}

// and in usage:
var placeholderUrl = getPlaceholder(fullSizeUrl);

if (placeholderUrl) {
  // there has been a smaller image loaded previously, so...
  addTheFadeInBehaviour();
} else {
  // no smaller image has been loaded so...
  loadFullSizeAsNormal();
}

Although, this technique is a bit involved and I’ve deliberately glossed over some of the details, it creates a nice visual effect, greatly reduces the perceived load time of the image, and it is especially effective for long-lived, single-page applications.