Serve files with nginx conditionally
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
ofC
. - Performance problem: server-level
if
is evaluated for every request, even when non-images are requested. The same is true for unrestricted location-levelif
directives.
- “Directive
- 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: 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
- Performance problem: it is a subject of several pitfalls including so-called “Taxing Rewrites”, when no regular expression is required, yet evaluated.
- 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.
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
triesimage.png.webp
. It is served, if found.- Otherwise
try_files
triesimage.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
triesimage.png
. It is served, if found.- Otherwise
try_files
tries againimage.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.