Lazy-Loading SVG Sprites… Inline!

Like most web developers, I celebrated the death of IE 8 by promptly replacing all of my hacky icon fonts with hacky SVG sprites. The sprite build process was vastly easier — just drop an image in a folder and let Grunt handle the rest — and the results were more consistent and flexible. Unlike font glyphs, SVG images can contain multiple colors, complex data, DOM paths — accessible to CSS and Javascript — and, most importantly, they never accidentally render as obscure Samaritan Unicode characters.

The industry, overall, was happy with the change and never looked back.

But while sprites are an unquestionable workflow win, the etymology hints at hidden dangers.

Randamnation!

"Sprite" or "spright", from the Latin spiritus, translates to "spirit" or "ghost", and is commonly used to describe the elusive and deceptive faire folk of European mythology. And just like Queen Mab, the shimmer and sheen is just so much glamour; underneath it all lies something ugly.

Take, for example, this tiny snippet from a well-optimized block of SVG code:

M258.5146 139.7192c1.878 0 2.816-1.565 2.816-3.755 0-2.87-1.669-6.728-4.694-9.858-2.973-3.076-7.196-5.215-12.256-5.372 3.494 9.232 9.441 18.985 14.134 18.985m5.007-24.304c-4.746-8.085-11.892-12.832-17.003-12.832-3.859 0-5.007 2.556-5.007 5.685 0 2.034.417 5.007 1.357 8.085h.729c7.302 0 12.987 2.869 16.847 6.623 4.485 4.382 6.258 9.702 6.258 13.665 0 4.539-2.554 8.346-7.353 8.346-8.606 0-16.639-12.153-20.551-23.68-4.067.94-7.207 4.503-7.207 8.242 0 5.856 3.213 6.983 2.131 8.607-1.502 2.251-6.346-1.849-6.346-8.398 0-4.903 2.818-10.484 10.171-12.622-.835-3.182-1.252-6.207-1.252-8.711 0-5.111 1.826-10.013 9.024-10.013 8.344 0 14.844 6.674 19.904 15.386-.261.781-.92 1.617-1.702 1.617m108.5292.0003c1.461 2.503 3.13 5.945 4.643 9.284-.939.99-1.618 2.085-1.618 3.338 0 2.451 2.774 4.971 4.642 5.527 1.87.557 3.393-.595 2.816-2.657-.576-2.06-1.981-3.966-1.981-3.966 2.086-2.034 6.207-4.278 6.207-7.615 0-.627-.156-1.512-.626-2.661l-2.347-5.789c-2.399-5.946-1.095-6.676-.26-6.676 1.981 0 5.708 3.349 8.527 10.044.623 1.481 2.517.99 2.03-.446-2.552-7.535-7.115-13.092-12.07-13.092-4.121 0-6.572 3.286-2.869 12.256l1.929 4.695c.418 1.042.471 1.565.471 1.773 0 1.043-1.044 1.93-2.713 3.338-1.304-2.817-2.816-5.945-4.538-8.97-.782 0-1.981.833-2.243 1.617m-287.4834-6.937c-.574.572-.939 1.354-.939 2.294 0 1.93 1.669 3.495 3.651 3.182 1.252 3.181 2.086 6.415 2.086 8.397 0 2.191-.939 3.077-2.138 3.077-4.59 0-9.806-12.987-9.806-17.838 0-1.981.887-3.181 2.295-3.181 1.617 0 3.338 1.67 4.851 4.069m15.37 5.32c-3.13-5.477-5.826-8.816-11.094-7.46-2.399-3.286-5.58-5.632-9.44-5.632-3.859 0-6.833 2.346-6.833 7.405 0 6.729 5.32 21.021 14.866 21.021 3.859 0 6.779-2.348 6.779-7.355 0-3.234-1.252-8.189-3.494-12.414 2.764-.574 4.804 1.159 7.673 6.009.781 0 1.282-.793 1.543-1.574

Now imagine randomly delicious code like that multiplied by, say, 50 or 100 times, and you've more or less found your way to the true nature of a sprite.

Obviously, adding a sprite like that to a given document will make that document bigger, and a bigger document will in turn take longer to download, parse, and render. But unless you're trying to recreate the whole of Font Awesome in SVG form, that difference shouldn't be more than 50KB or so, peanuts compared to the total "page size" once all of your images and scripts and other assets have been pulled down.

And yet, documents with SVG sprites do take significantly longer to process than documents without them. Stranger still, this is equally true for both dynamic and static content. So if size doesn't matter, what's the problem?

In a word: it's random.

All those path points printed previously? Compared to the rest of the page, which will be made up of lots of repeated words and phrases (as well as repeated markup), SVG paths are just so much noise. That noise, in turn, creates a lot of extra work for server-side encoders like Gzip and Brotli, which do their best to find and squish patterns to save on bandwidth and transfer times. More work for Gzip and Brotli means more strain on the CPU and more delay before a server can send the response off to the interested party.

Were those delays on any other kind of asset, the difference might not be noticed. But that initial document response is crucial. Without it, a browser cannot do anything but show a blank white screen. At the scale we're talking about — hundreds of milliseconds — all that nothingness is painful.

Lazy-Loading to the Rescue

One of the primary benefits of embedding SVG code into an HTML document is that once there, they're part of the DOM, allowing them to be styled, animated, copied, moved, etc., just like any other element on the page. These benefits go away anytime an SVG is referenced externally — e.g. <img src="https://domain.com/img/logo.svg" /> — so for our purposes, we can ignore any suggestions along those lines.

The key, as it turns out, can be found in the first sentence of the preceding paragraph. SVGs are code, code which can be manipulated with Javascript. And since Javascript can add or remove code from the page at runtime, that means it can add or remove SVG code at runtime.

This chain of reasoning recently manifested as a feature in my Vue plugin What Goes Around — a simple lazy-loading/screen-visibility helper.

<-- You write something like this: -->
<svg v-lazy="{ inline: 'https://domain.com/img/logo.svg' }"></svg>

<-- Once lazy loading triggers, the source is fetched, parsed,
     and transferred to the original element, like: -->
<svg xmlns="http://www.w3.org/2000/svg" width="807.013" height="351.364" version="1.1" viewBox="0 0 807.013 351.364"> … </svg>

Once the source finds its way to the DOM, it is welcomed with arms wide open, even in the browser that won't die IE 11.

But I appreciate that Vue is not everybody's cup of tea, so let's break down the essentials into vanilla Javascript so everybody can play!

Ajax the Great

What Goes Around is designed for lazy-loading, so contains a lot of logic around monitoring where an element is relative to the current screen. But none of that is relevant here. Let's simplify things with two basic assumptions:

/**
 * Process SVGs onLoad
 *
 * @returns {void} Nothing.
 */
window.addEventListener('load', function() {
	// Find our SVGs.
	const svgs = document.querySelectorAll('svg[data-url]');
	const svgsLen = svgs.length;

	// Loop and process.
	for (let i = 0; i < svgsLen; ++i) {
		// Grab the URL and delete the attribute; we no longer
		// need it.
		let url = svgs[i].getAttribute('data-url');
		svgs[i].removeAttribute('data-url');

		// We'll let another function handle the actual fetching
		// so we can use the async modifier.
		fetchSVG(url, svgs[i]);
	}
});

/**
 * Fetch an SVG
 *
 * @param {string} url URL.
 * @param {DOMElement} el Element.
 * @returns {void} Nothing.
 */
const fetchSVG = async function(url, el) {
	// Dog bless fetch() and await, though be advised you'll need
	// to transpile this down to ES5 for older browsers.
	let response = await fetch(url);
	let data = await response.text();

	// This response should be an XML document we can parse.
	const parser = new DOMParser();
	const parsed = parser.parseFromString(data, 'image/svg+xml');
	
	// The file might not actually begin with "<svg>", and
	// for that matter there could be none, or many.
	let svg = parsed.getElementsByTagName('svg');
	if (svg.length) {
		// But we only want the first.
		svg = svg[0];

		// Copy over the attributes first.
		const attr = svg.attributes;
		const attrLen = attr.length;
		for (let i = 0; i < attrLen; ++i) {
			if (attr[i].specified) {
				// Merge classes.
				if ('class' === attr[i].name) {
					const classes = attr[i].value.replace(/\s+/g, ' ').trim().split(' ');
					const classesLen = classes.length;
					for (let j = 0; j < classesLen; ++j) {
						el.classList.add(classes[j]);
					}
				}
				// Add/replace anything else.
				else {
					el.setAttribute(attr[i].name, attr[i].value);
				}
			}
		}

		// Now transfer over the children. Note: IE does not
		// assign an innerHTML property to SVGs, so we need to
		// go node by node.
		while (svg.childNodes.length) {
			el.appendChild(svg.childNodes[0]);
		}
	}
};

That, ladies and gentlemen, is it!

How Less Terrible Is This?

Using an existing, established site as a guinea pig, I selected a page with a total of 22 inline SVGs, 17 of which were unique and grouped into a single sprite. (And no, the sprite did not contain any images not otherwise present on the page.) Various load metrics were then averaged across 20 uncached visits, then compared with the metrics from the same page using What Goes Around instead of a sprite.

The HTML responses in both cases were dynamically compressed by Nginx using Gzip with a modest level of 6.

In terms of raw size, the lazy-loaded version came in slightly smaller — 9.2KB vs 30.8KB Gzipped — but much faster. On average, that first response was received 200ms faster when lazy-loading versus spriting. Even more surprising, the overall page load, despite all the extra requests, came in a whopping 345ms faster.

Thanks to HTTP/2's ability to handle multiple requests simultaneously — and our Javascript callback's ability to send them simultaneously — the extra requests didn't alter the waterfall very much. (Thanks unrelated technical advances!)

But aside from that, separating out the static SVGs from a dynamically-generated page allowed those assets to be pre-compressed, full strength, with both Gzip and Brotli. While not feasible to employ such forces on-the-fly — compression time would exceed any transfer savings — when done as part of a build process, it's no big deal. Nginx is smart enough to serve pre-compressed copies of assets like SVGs to browsers that support them, shaving an additional kilobyte or two off the total page size.

One final factor that makes this magic possible: cache. By default, browsers will only ask a server for a given asset once; after that, they'll just pluck it from the local disk cache. That's handy, as this particular page contains five icons — obligatory social media plugs — that each appear in two separate locations. Lazy-loading them resulted in a total of five HTTP requests rather than ten.

Before You Go Changing the World…

I, for one, am convinced. Not only does this approach increase site performance while decreasing server load, everything in this article works out-of-the-box in all major modern browsers. The next time Blobfolio gets a facelift, SVG lazy-loading will definitely factor in.

But that said, it can take a little trial and error to get this working in older browsers. Transpiling the code down to ES5 is a good start, but depending on the browser, you will likely run into some strange issues. IE 11, for example, forgot to implement classList on SVG elements. Haha. For sites needing broader support, lazy-loading will need to be accompanied by one or more polyfills. Services like polyfill.io are a good place to start, though you should probably self-host any code you end up serving to your visitors.

As with anything else, just be sure to test things out on a staging environment before you push it live.

Josh Stoik
29 June 2018
Previous PHP 7.2 on Debian Stretch
Next Enable TLS 1.3 in Nginx on Debian Stretch