Concept
I’m not usually as much into web performance as some, but I had a project in mind recently that included a requirement to load a large number of small photos onto a single page. On this project, I wanted to nail the technical approach and attend to every detail, from using optimizing php compilers & memcached to cooperating with the network for optimized transfer times. So, I put some thought into how to best deal with the high image counts. The solution I came up with was a little different from anything I’d seen before, and delivers complete page load times that routinely exceed 3x speedup over no optimization. It’s got a different set of benefits and drawbacks from other optimizations out there, but for typical use cases involving lots of image tags on one page it pretty much puts the standard css sprites tactic to shame.
The basic concept is similar to CSS sprites: since the major http server and browser manufacturers failed to provide the world with bug-free and interoperable http pipelineing, page authors can attain better performance by reducing the total number of requests their pages require. This minimizes time between reuquests when no data is actually getting transferred. In order to retain the multimedia richness of the page yet reduce requests for separate image resources, you need to find clever ways of packaging multiple resources into a single http response.
My twist on this was to package the multiple images by base64-encoding their byte streams and framing them with JSON, instead of packing many distinct images into one bigger image file and taking scissors to it on the client. I call it “JSON packaging.” It might sound impractical and unlikely at first, but the downsides that may initially come to mind (base64 space and decoding overhead) turn out to have surprising silver linings, and on top of that it has some nice additional advantages over css sprites.
First, to retain this post’s credibility and your interest, I’ll try to lower the first big red flag that may be in your mind at this point - when you base64 encode binary streams with 8-bit character encodings, you make them 33% bigger. A little more overhead is added by the JSON notation. How can any attempt at optimization succeed if it requires 33% more data to be transmitted? Well, on many networks it wouldn’t, but thanks to gzip compression, that’s not what ends up happening. Applying gzip compression to the completed JSON object produces an unexpected result: the compressed base64 data actually becomes smaller than the sum of the sizes of the original images, even if the original images are also gzipped. Why is this? Because gzip has more than one image’s worth of data to work with, it can identify and compress more occurrences of more patterns. For this reason you would expect better compression if you compressed all the original images as one big file (and css sprites also benefit from this phenomenon), but starting with a 33% overhead due to the base64 encoding, this outcome defies conventional wisdom.
Okay, so what are the advantages of JSON packaging over css sprites?
- Unlike with css sprites and usual front-end performance rules, you don’t have to keep track of the dimensions of each image. With css sprites, this is a must for everything to get cropped and displayed as intended. (though I’ve seen IE fail and show edges of other sprites on tightly-packed backing images when page zoom isn’t 100% anyway.) In addition, because large batches of images are presented to the renderer in one shot, page reflow counts are kept in check when you don’t know or choose not to state the image’s size in markup. This helps to counteract the cost of the base64-decode.
- It’s easier to dynamically generate these puppies than CSS sprites. You almost never see CSS sprites constructed for anything beyond a set of static icons and branding of the partiular site. One reason for this is because you would need to set up webservers that could collect the images of interest, render them all onto a new canvas, and recompress them into a new image file in a performant fashion. With JSON packaging, the load on the servers for generating customized packed resources is reduced.
- It’s much better suited to photography, since it does not require an additional lossy recompress - the original image file’s bytes are what is rendered.
- You can use appropriate color palletes, bit depths, and compression formats on an image-by-image basis, yet still achieve the enhanced gzip performance of css sprites.
Proof-of-concept
I put together a few proof-of-concepts which you can check out for yourself. I also measured some performance data from these. Each example contains two static html pages and 100 img tags. One page is unoptimized - URLs to each individual image are included in the img tags. The other page references four external <script> resources and includes no src for the img tags.
Example 1
100 small jpg’s. 4kb to 8kb per file. Unoptimized | JSON-Packaged
Example 1 is designed to approximate the typical real-world scenarios. In particular, when you’re showing a bunch of images on one page, typically each individual image isn’t all that big (in this example the long dimension is 200px.) In most all permutations of network type, browser, and client CPU speed that I have tried, the JSON-Packaged case performs better during initial page loads, and usually at least halves total page time. Here’s some specific figures from some of my test runs to give you an idea. These are approximate averages of multiple runs, not highly scientific but intended to illustrate what is typical:
Initial load times
Computer | ||
---|---|---|
Browser | 3 Ghz Core 2 Quad (Q6600) | 1.6 Ghz Atom (N270) |
IE 9 |
508 ms packaged 1,127 ms unpackaged |
540 ms packaged 1,672 ms unpackaged |
FF 13 |
466 ms packaged 1,117 ms unpackaged |
723 ms packaged 1,590 ms unpackaged |
This table shows time to render the complete page if the files are cached and not modified, but the browser revalidates with the server each resource to see if it has changed:
Cached load times (revalidated)
Computer | ||
---|---|---|
Browser | 3 Ghz Core 2 Quad (Q6600) | 1.6 Ghz Atom (N270) |
IE 9 |
172 ms packaged 1,197 ms unpackaged |
203 ms packaged 1,200 ms unpackaged |
FF 13 |
180 ms packaged 1,062 ms unpackaged |
180 ms packaged 1,079 ms unpackaged |
This table shows time to render the complete page when techniques like expire times have been used to inform the browser’s caching system that it’s not necessary to revalidate the resource, and no roundtrips to the server occur:
Cached load times (not revalidated)
Computer | ||
---|---|---|
Browser | 3 Ghz Core 2 Quad (Q6600) | 1.6 Ghz Atom (N270) |
IE 9 |
120 ms packaged 16 ms unpackaged |
145 ms packaged 64 ms unpackaged |
FF 13 |
62 ms packaged 42 ms unpackaged |
375 ms packaged 320 ms unpackaged |
I obtained these figures over the public Internet with a latency to the server of 54 ms and bandwidth of about 15 mbps. Results favor packaging even more on more latent links. On very low latency networks like a LAN, this is not an optimization, but the whole thing is sufficiently fast either way in that environment. Similarly, if cached on the client in such a way that the client does not revalidate each resource with the server, this is not an optimization, but the inconsistent results above show that at this speed, other factors play a bigger role in overall load time, but the whole thing is sufficiently fast no matter what.
Finally, here’s a table of total bytes that need to be transferred with the packaged/unpackaged samples under various compression scenarios. As you can see, packaging also results in a little bit less data transfer:
Bytes: base64 in JSON vs. separate files
Sample | 100 Separate Files | Base64 in four JSON objects | ||||
---|---|---|---|---|---|---|
best gzip | fastest gzip | no compression | best gzip | fastest gzip | no compression | |
small jpg (mostly 200×133 px) | 509,883 (-2.3%) | 510,630 (-2.1%) | 521,763 (reference) | 460,281 (-11.8%) | 473,169 (-9.3%) | 696,372 (+33.5%) |
Example 2
100 medium jpg’s. 40kb to 90kb per file. Unoptimized | JSON-Packaged
Example 2 is designed to experiment with how far you can take this - it uses much larger image files just to see what happens. The dimensions of these images are large enough that it doesn’t work well to show them all on one page, so this is probably not a typical real-world use case. Here the results I obtained favor the “unoptimized” version due to the increased base64 decoding overhead. Thus, I’ll leave it at that - you’re welcome to test it out yourself for some specific figures.
Implementation Notes
Once you have your image data as base64-encoded JavaScript strings, some very light JavaScript coding is all that is needed to produce images on your page. Thanks to the often-ignored data: protocol handler supported by all browsers nowadays (detail here), all you need to do is set your image’s src attribute to the base64-encoded string, with a little static header indicating how the data is encoded. In my example, I pass a JS array of base64 strings to the function unpack_images, which simply assigns it to an img already on the document. In an application you would invent a more complex scheme to map the base64 data to a particular img in the DOM, such as creating the DOM images on-the-fly or including image names in the JSON.
function unpack_images(Packages) {
for(var i = 0; i < Packages.length; i++)
{.getElementById('img' + i).src = "data:image/jpg;base64," + Packages[i];
document
} }
Using four separate js files to package the images wasn’t an arbitrary decision - this allows the browsers to fetch the data over four concurrent TCP streams, which results in faster transfers overall due to the nature of the beast. (This is what makes this approach superior to simply stuffing all your data into one massive integrated html file.) Also, I tweaked my initial version of Example 1 a little bit to enable the base64-decoding to commence immediately when the first js file has completed transferring, while the remaining files still finish up. To do this, place your unpack_images function in a separate <script> tag, and somewhere below that in your html page add script tags to your js files with the defer attribute:
<!-- image data package scripts -->
<script type="text/javascript" src="package-1.js" defer></script>
<script type="text/javascript" src="package-2.js" defer></script>
<script type="text/javascript" src="package-3.js" defer></script>
<script type="text/javascript" src="package-4.js" defer></script>
Then, just wrap your JSON data in a call to unpack_images directly in your package.js files (yes, it’s not a pure JSON object anymore):
unpack_images(["base64data_for_image_1", "base64data_for_image_2", ...]);
This tweak saves 80 - 100 ms over loading all the data first and then decoding it in my Example 1.
All the content in these examples except the individual image files was generated using this script, if you want to pick it up and run with it.
Conclusion
By my analysis, this technique seems to put css sprites to shame in just about any use case. As a word of caution, though, both css sprites and JSON packaging don’t play very nice with browser caches, since they only allow the storage of one entity per http request. Consider the common case where a summary page shows dozens of product images, each linking to a product details page. The first time the user visits your site’s summary page, you are probably better off delivering the images in packages. On the other hand, you want to avoid referencing the packaged set on the product details page in case the user entered your site directly to the details page, but it would be nice if you could fetch the particular product’s image from the already cached package if you’ve got it in cache already. It’d be nice if there was a JavaScript API that allowed you to save script-generated resources to the browser cache with any url under the window.domain, but until that happens this is the ugly side of css sprites and JSON packaging.