Time and again working on big web applications we customize files based on user’s platform, and their preferences. We can send different files to legacy browsers, different CSS and JS to mobile browsers depending on their form factor, different images to accomodate bandwidth requirements, and so on.

This post was prompted by my desire to serve sprites produced by grunt-tight-sprite as WebP images to WebP-capable browsers falling back to “classic” image formats for the rest using nginx. While it is hardly a new topic, I was not satisfied with existing solutions, which all used if and rewrite , instead of simpler methods.

Obviously the core solution can be used to serve almost any file conditionally, not only images, but all examples will be about WebP.

The problem at hand

One common technique to conserve connections and bandwidth, especially on mobile platforms, is to use sprites instead of individual images. See Sprites by CSS on Wikipedia for more details. While the upcoming HTTP 2.0 will reduce the need for sprites by using one connection to request images (and other files) asynchronously, the technique will still be valid due to a possible bandwidth gain by compressing several images together.

There are several tools to incorporate sprites in web developer’s workflow. I work on one of them: grunt-tight-sprite (see its wiki for more details), and I know the problem first-hand.

Generally sprites (or sprite sheets) are relatively big images, so every bit of extra compression helps. That’s why we crush resulting sprites with zopflipng or with jpegtran. We take our images to the limit.

But can we do better? Enter WebP.

About WebP

WebP is “a new image format that provides lossless and lossy compression for images on the web.” It improves on lossless PNG and lossy JPEG without compromizing on visual quality, and implements new features, like lossy alpha channel.

WebP-capable browsers advertise themselves by including "image/webp" string in an HTTP Accept header.

Right now Chrome and Opera support WebP on desktop and Android platforms (see Can I use WebP image format? for more details). Firefox may support WebP in future versions.

Given the popularity of Chrome, and gazillions of Android devices on the market, it makes perfect sense to support WebP in addition to legacy formats.

Our goal

Now we can sketch the final goal:

Let’s clone our images and produce WebP version of them.

If user’s browser supports WebP, we will send them a WebP version. If we don’t have a WebP version for some reason, we will send a legacy version.

All other users will receive a PNG or JPEG image.

Obviously, to reduce expenses on dynamic generation of images, we will convert them to WebP statically. grunt-tight-sprite has a recipe for that.

Now we have to serve files making decisions. Let’s remove from the table web services, and other high-level solutions – they are too heavy for our needs. Let’s leverage our web server: both Apache and nginx can do it using “native” tools.

Existing solutions

Hitting the web we will find a lot of good battle-tested recipes for nginx. The problem is that all of them “smell” like Apache + C: they use if and rewrite to achive their goal.

One of the best articles on this topic is Deploying WebP via Accept Content Negotiation (code repository) by Ilya Grigorik of Google – it is small, concise, practical. Yet again, it relies on if and rewrite .

What’s wrong with if and rewrite ?

According to nginx Wiki “if is evil“ (yes, this is how it is phrased in Wiki). “Directive if has problems when used in location context, in some cases it doesn’t do what you expect but something completely different instead. In some cases it even segfaults. It’s generally a good idea to avoid it if possible.” It is frequently misunderstood, and misused. In short: it is not if of C . Performance problem: server-level if is evaluated for every request, even when non-images are requested. The same is true for unrestricted location-level if directives.

Obviously rewrite works, but: We make assumptions, while rewriting. For example, it will redirect, even if new URI points to a non-existent WebP file. We should serve a legacy file instead, yet there is no simple recourse for that. Performance problem: the best existing solutions check for a file first, and account for its absense. Yet they do the check even when we know that user cannot accept WebP due to limitations of if . Performance problem: it is a subject of several pitfalls including so-called “Taxing Rewrites”, when no regular expression is required, yet evaluated.

works, but:

All those problems are usually present in existing solutions I found on the web.

Let’s try something completely different.

Proposed solution

The idea is still the same: we check HTTP Accept header for "webp" , and if it is there, we serve an alternative file. But instead of if and rewrite we will use lightweight map and try_files :

user www-data; http { ## # Basic Settings ## sendfile on; tcp_nopush on; tcp_nodelay on; # IMPORTANT!!! Make sure that mime.types below lists WebP like that: # image/webp webp; include /etc/nginx/mime.types; default_type application/octet-stream; gzip on; gzip_disable "msie6"; ## # Conditional variables ## map $http_accept $webp_suffix { default ""; "~*webp" ".webp"; } ## # Minimal server ## server { listen 80 default_server; listen [::]:80 default_server ipv6only=on; root /usr/share/nginx/html; index index.html; # Make site accessible from http://localhost/ or whatever you like server_name localhost; location ~* ^/images/.+\.(png|jpg)$ { root /home/www-data; add_header Vary Accept; try_files $uri$webp_suffix $uri =404; } } }

This is it. The real configuration file is likely to use more cache-control headers, but what we have here is realistic enough to demonstrate the technique.

For your convenience, the snippet above is available as GitHub Gist.

How it works

Only three parts are important: mime.types should list webp , we should define a variable depending on HTTP Accept header, and we should use it in try_files .

mime.types

nginx uses a file to list mappings from file extensions to MIME types. Usually it is called mime.types , and included externally. Make sure that it lists webp :

image/webp webp ;

Otherwise, a file will be sent as application/octet-stream , which will prompt a user to download it rather than show it inline.

map

map defines a variable that depends on values of other variables (see ngx_http_map_module for details). This module is included in nginx by default, you don’t need to recompile anything.

map $http_accept $webp_suffix { default "" ; "~*webp" ".webp" ; }

This http -level snippet defines a variable called $webp_suffix , which depends on $http_accept (our HTTP Accept header). If the header contains "webp" substring (using a case-insensitive regular expression), than our variable will be set to ".webp" , otherwise it will be an empty string.

Strictly speaking defining a default as an empty string is superfluous: this is the default behavior anyway. I added this excessive line here for clarity.

Interesting thing about nginx’s variables is that they are all lazily calculated, so we can define a lot of them without slowing down our server – only variables, which we actually use, will be evaluated. In our case, it means that adding our variable does not affect serving other non-image files.

try_files

This is a workhorse of the solution:

try_files $uri$webp_suffix $uri = 404 ;

This location -level directive checks files conditionally breaking on success:

Checks a file + a possible ".webp" suffix, and serves it, if it is found. Checks a file as it was requested, and serves it, if it is found. Sends HTTP404 (AKA “not found”), if not found.

Example

Let’s go over it in details. We assume that we have file called image.png and its possible counterpart image.png.webp .

User comes with a WebP-capable browser: $webp_suffix is set to ".webp" . try_files tries image.png.webp . It is served, if found. Otherwise try_files tries image.png (the original file). It is served, if found. Otherwise “not found” is returned. User comes with a browser that knows nothing about WebP: $webp_suffix is set to "" . try_files tries image.png . It is served, if found. Otherwise try_files tries again image.png (the original file). While it is clearly redundant, it is unlikely that an image is not found. If this is a common situation for an application you develop, remember that nginx caches files, so it will likely be amortized. Otherwise “not found” is returned.

try_files is a core module directive. It may check files on disk, redirect to proxies, or internal locations, and return error codes, all in one directive. See try_files for more details.

Summary

Our solution uses a simplified approach based on map and try_files . It avoids common performance pitfalls without complicating our logic.

This approach can be extended on serving other files using HTTP headers or their combination as a source for decision making. For example, it is possible to set a cookie on the client side using JavaScript, then check for it in nginx serving different files, when requested. It is even possible, though I would recommend against it, to sniff a user agent string, and make a decision what to serve based on browser.

Appendix: JPEG XR

Google is not alone in defining a better image specification. Microsoft came out to play with its JPEG XR, which is a serious contender too. It is supported in their line of Internet Explorer browsers starting with version 9, and grunt-tight-sprite has a recipe for producing JPEG XR files.

Obviously it would be nice to send JPEG XR-encoded sprites to IE9+. Unfortunately it appears that those browsers do not indicate that they accept this advanced image format. The only way is to parse and interpret User Agent string, which is unreliable, and generally discouraged even by Microsoft.

Why did they do that? Apparently there is a school of thought that if we add all accepted types to HTTP Accept header, which is sent with every HTTP request, all communications will be extremely bloated. The latter is exacerbated by the fact that headers are sent uncompressed (will be fixed in HTTP 2.0). What is their solution? I didn’t see any yet. Which leaves us with User Agent, sigh.

So I will leave the implementation of sending JPEG XR conditionally as an exercise for my readers. To get you started, here is a hint: don’t forget to add necessary MIME types to mime.types :

image/vnd.ms-photo jxr wdp hdp ;

Notes

This post was originally started as grunt-tight-sprite’s Recipe: serve WebP with nginx conditionally on project’s wiki.

This post uses the image by Sugree Phatanapherom under Creative Commons License.