• Building a Faster Lightbox

    Tumblr’s new Photoset feature was hinged on being able to quickly display high-res images in an interactive lightbox slideshow. You can check out an example with my dog.

    After solidifying the design, we spent a couple days doing everything we could to improve the real and perceived responsiveness. Here’s what we learned along the way.

    Know your image dimensions beforehand. Tipping off your lightbox to the high-res image dimensions is a great cheat for drawing placeholders or running animations before your high-res image has loaded.

    Draw placeholders. Our first inclination was to simply show a spinner. But we found that drawing an empty grey box with the correct dimensions felt much less jarring when the high-res image finally popped in. Or even better…

    Start with your thumbnails. If you have to wait a few seconds for a high-res photo to load on a page where you were already displaying a low-res thumbnail, scaling up the low-res image can make the lightbox feel lightning responsive and give you something to look at before the high-res version is ready.

    Take control of image loading. Browsers do a great job of asynchronously loading images, but simply drawing an <IMG> tag with your high-res image and waiting for it to pop in doesn’t give you nearly as much control as you could have by running the process yourself.

    This is done by starting the image load in Javascript, doing whatever you want while the image is downloading (drawing a placeholder, animating, etc.), and adding the loaded image to the page once it completes. This becomes especially important when wrangling a multi-photo lightbox.

    Here’s a crude example of the above points:

    <img src="thumbnail.jpg"
    onclick="zoom('high-res.jpg', 1024, 768, this.src)"/>
    
    <script type="text/javascript">
        function zoom(high_res_url, width, height, low_res_url) {
            // Draw placeholder image
            var lightbox_img = document.getElementById('lightbox_img');
            lightbox_img.src = low_res_url ? low_res_url : 'empty.gif';
            lightbox_src.style.width = width + 'px';
            lightbox_src.style.height = height + 'px';
    
            show_lightbox();
    
            // Load high-res image
            var high_res_img = new Image();
            high_res_img.onload = function(){
                lightbox_img.src = this.src;
            };
            high_res_img.src = high_res_url;
        }
    </script>

    There are two ways to get the loaded image onto the page: The above, which sets the placeholder <IMG> src to the high-res image URL after the image is already loaded on the page — perhaps not the most scrupulous approach. Or by dropping the entire Image object onto the page with an appendChild(). We found both options worked consistently across browsers with no noticeable performance difference.

    Don’t wait around for cached images. You’d expect any image that has already been loaded on a page (e.g. a thumbnail) to fire its onload event immediately when re-inserted. But we found delays north of 100ms in some browsers. Here’s a simple optimization using the Image object’s complete property:

    var low_res_img = new Image();
    low_res_img.onload = function(){
        ...
    };
    low_res_img.src = thumbnail_src;
    
    if (low_res_img.complete) {
        low_res_img.onload();
        low_res_img.onload = function(){};
    }

    Make sure you manage your asynchronicity. Once you’re managing user input and image loading for a gallery of photos, things can get hairy quickly. There are a bunch of methods for keeping all of this asynchronous activity under control. Here’s how it works on Tumblr:

    Our lightbox has three persistent image elements called “stages” that we resize to display the focused images.

    This saves us from constantly adding and removing or showing and hiding image elements on the page. When the lightbox is launched or the user flips to the next image, we empty each stage, insert or load the thumbnails, and start loading the high-res images. When a thumbnail or image is ready, it’s pushed to its stage.

    Easy. But there are a couple things to watch out for:

    • Thumbnails might end up loading after the high-res image, which you definitely don’t want to replace.
    • By the time an image is loaded, the user might have already proceeded to another image in the gallery.

    To make sure we never replace a staged image with its thumbnail or an image that was previously being loaded for that stage, we use check-and-set (CAS) keys. These keys get generated and sent to each stage and loading image on every user interaction. When an image load completes, it will only replace the staged image if its CAS key matches that of the stage.

    A crude and verbose example for the purposes of demonstration:

    var cas = 0;
    var stage = document.getElementById('stage');
    
    function zoom(high_res_url, low_res_url) {
        // Generate a new cas key
        cas = cas + 1;
    
        // Update the stage cas
        stage.cas = cas;
    
        // Clear the stage
        stage.src = 'empty.gif';
    
        // Start loading the low-res image
        var low_res = new Image();
        low_res.cas = cas;
        low_res.onload = function(){
            if (this.cas == stage.cas) {
                stage.src = this.src;
            }
        };
        low_res.src = low_res_url;
    
        // Start loading the high-res image
        var high_res = new Image();
        high_res.cas = cas;
        high_res.onload = function(){
            if (this.cas == stage.cas) {
                // Make sure the low-res image
                // won't replace this
                stage.cas = false;
    
                stage.src = this.src;
            }
        };
        high_res.src = high_res_url;
    }

    Load ahead. With Tumblr’s Photosets allowing ten photos made up of images that occasionally approach 1MB each, it would have been impractical to start them all loading as soon as the lightbox opened. Luckily, since our lightbox only lets you navigate to the “next” or “previous” image, we’re able to start loading the next high-res image in sequence before the user gets there. We only load one image ahead of the current position and the improvement in responsiveness is dramatic.

    Dynamically degrade the interface to improve performance. A fancy fullscreen lightbox has the potential to sorely impact window resizing performance. We found the biggest culprit in our design was the vignette we draw around the window, either with an inner box-shadow or an alpha PNG. To help performance without sacrificing our design, we simply hide these elements that are expensive to render while the window is being resized. Ideally, this is done by adding a CSS class to your lightbox. Here’s a crude example with basic debouncing:

    var finished_resizing_timeout;
    
    window.onresize = function(){ // Use your event library of choice
        if (document.getElementById('lightbox')) {
            document.getElementById('lightbox').className = 'resizing';
    
            if (finished_resizing_timeout) {
                clearTimeout(finished_resizing_timeout);
            }
    
            finished_resizing_timeout = setTimeout(function(){
                document.getElementById('lightbox').className = '';
            }, 100);
        }
    }

    Serve those images from CDN! I’d be remiss if I didn’t also mention this one. There’s a noticeable difference when serving assets from a global content network versus a single server or high-latency origin like S3.