Fantastic

Fantastic front end performance, part 3 – Big performance wins by optimizing fonts – A Node.js holiday season, part 8

This is episode 8, out of a total 12, in the A Node.JS Holiday Season series from Mozilla’s Identity team. Today we’re talking even more front end performance!

We reduced Persona’s font footprint 85%, from 300 KB to 45 KB, using font subsetting. This post outlines exactly how we implemented these performance improvements, and gives you tools to do the same.

Introducing connect-fonts

connect-fonts is a Connect font-management middleware that improves @font-face performance by serving locale-specific subsetted font files, significantly reducing downloaded font size. It also generates locale/browser-specific @font-face CSS, and manages the CORS header required by Firefox and IE 9+. Subsetted fonts are served from a font pack–a directory tree of font subsets, plus a simple JSON config file. Some common open-source fonts are available in pregenerated font packs on npm, and creating your own font packs is straightforward.

(Feeling lost? We’ve put together a few references to good @font-face resources on the web.)

Static vs dynamic font loading

When you are just serving one big font to all your users, there’s not much involved in getting web fonts set up:

  • generate @font-face CSS and insert into your existing CSS
  • generate the full family of web fonts from your TTF or OTF file, then put them someplace the web server can access
  • add CORS headers to your web server if fonts are served from a separate domain, as Firefox and IE9+ enforce the same origin policy with fonts

These steps are pretty easy; the awesome FontSquirrel generator can generate all the missing font files and the @font-face CSS declaration for you. You’ve still got to sit down with Nginx or Apache docs to figure out how to add the CORS header, but that’s not too tough.

If you want to take advantage of font subsetting to hugely improve performance, things become more complex. You’ll have font files for each supported locale, and will need to dynamically modify the @font-face CSS declaration to point at the right URL. CORS management is still needed. This is the problem connect-fonts solves.

Font subsetting: overview

By default, font files contain lots of characters: the Latin character set familiar to English speakers; accents and accented characters added to the Latin charset for languages like French and German; additional alphabets like Cyrillic or Greek. Some fonts also contain lots of funny symbols, particularly if they support Unicode (? anyone?). Some fonts additionally support East Asian languages. Font files contain all of this so that they can ably serve as many audiences as possible. All this flexibility leads to large file sizes; Microsoft Arial Unicode, which has characters for every language and symbol in Unicode 2.1, weighs in at an unbelievable 22 megabytes.

In contrast, a typical web page only needs a font to do one specific job: display the page’s content, usually in just one language, and usually without exotic symbols. By reducing the served font file to just the subset we need, we can shave off a ton of page weight.

Performance gains from font subsetting

Let’s compare the size of the localized font files vs the full file size for some common fonts and a few locales. Even if you just serve an English-language site, you can shave off a ton of bytes by serving an English subset.

Smaller fonts mean faster load time and a shorter wait for styled text to appear on screen. This is particularly important if you want to use @font-face on mobile; if your users happen to be on a 2G network, saving 50KB can speed up load time by 2-3 seconds. Another consideration: mobile caches are small, and subsetted fonts have a far better chance of staying in cache.

Open Sans regular, size of full font (default) and several subsets (KB):

Chart comparing file sizes of Open Sans subsets. Full font, 104 KB. Cyrillic, 59 KB. Latin, 29 KB. German, 22 KB. English, 20 KB. French, 24 KB.

Same fonts, gzipped (KB):

Chart comparing file sizes of Open Sans subsets when gzipped. Full font, 63 KB. Cyrillic, 36 KB. Latin, 19 KB. German, 14 KB. English, 13 KB. French, 15 KB.

Even after gzipping, you can reduce font size 80% by using the English subset of Open Sans (13 KB), instead of the full font (63 KB). Consider that this is the reduction for just one font file–most sites use several. The potential is huge!

Using connect-fonts, Mozilla Persona’s font footprint went from 300 KB to 45 KB, an 85% reduction. This equates to several seconds of download time on a typical 3G connection, and up to 10 seconds on a typical 2G connection.

Going further with optimizations

If you’re looking to tweak every last byte and HTTP request, connect-fonts can be configured to return generated CSS as a string instead of a separate file. Going even further, connect-fonts, by default, serves up the smallest possible @font-face declaration, omitting declarations for filetypes not accepted by a given browser.

Example: adding connect-fonts to an app

Suppose you’ve got a super simple express app that serves up the current time:

// app.js
const
ejs = require('ejs'),
express = require('express'),
fs = require('fs');
 
var app = express.createServer(),
  tpl = fs.readFileSync(__dirname, '/tpl.ejs', 'utf8');
 
app.get('/time', function(req, res) {
  var output = ejs.render(tpl, {
    currentTime: new Date()
  });
  res.send(output);
});
 
app.listen(8765, '127.0.0.1');

with a super simple template:

// tpl.ejs
<!doctype html>
<p>the time is <%= currentTime %>.

Let’s walk through the process of adding connect-fonts to serve the Open Sans font, one of several ready-made font packs.

App changes

  1. Install via npm:

     $ npm install connect-fonts
     $ npm install connect-fonts-opensans
  2. Require the middleware:

     // app.js - updated to use connect-fonts
     const
     ejs = require('ejs'),
     express = require('express'),
     fs = require('fs'),
     // add requires:
     connect_fonts = require('connect-fonts'),
     opensans = require('connect-fonts-opensans');
     
     var app = express.createServer(),
       tpl = fs.readFileSync(__dirname, '/tpl.ejs', 'utf8');
  3. Initialize the middleware:

      // app.js continued
      // add this app.use call:
      app.use(connect_fonts.setup({
        fonts: [opensans],
        allow_origin: 'http://localhost:8765'
      })

    The arguments to connect_fonts.setup() include:

    • fonts: an array of fonts to enable,
    • allow_origin: the origin for which we serve fonts; connect-fonts uses this info to set the Access-Control-Allow-Origin header for browsers that need it (Firefox 3.5+, IE 9+)
    • ua (optional): a parameter listing the user-agents to which we’ll serve fonts. By default, connect-fonts uses UA sniffing to only serve browsers font formats they can parse, reducing CSS size. ua: 'all' overrides this to serve all fonts to all browsers.

  4. Inside your route, pass the user’s locale to the template:

     // app.js continued
     app.get('/time', function(req, res) {
       var output = ejs.render(tpl, {
         // pass the user's locale to the template
         userLocale: detectLocale(req),
         currentTime: new Date()
       });
       res.send(output);
     });
  5. Detect the user’s preferred language. Mozilla Persona uses i18n-abide, and locale is another swell option; both are available via npm. For the sake of keeping this example short, we’ll just grab the first two chars from the Accept-Language header:

     // oversimplified locale detection
     function detectLocale(req) {
       return req.headers['accept-language'].slice(0,2);
     }
     
     app.listen(8765, '127.0.0.1');
     // end of app.js

Template changes

Now we need to update the template. connect-fonts assumes routes are of the form

    /:locale/:font-list/fonts.css

for example,

    /fr/opensans-regular,opensans-italics/fonts.css

In our case, we’ll need to:

  1. add a stylesheet <link> to the template matching the route expected by connect-fonts:

     // tpl.ejs - updated to use connect-fonts
     <!doctype html>
     <link href="/<%= userLocale %>/opensans-regular/fonts.css" rel="stylesheet">
  2. Update the page style to use the new font, and we’re done!

     // tpl.ejs continued
     <style>
       body { font-family: "Open Sans", "sans-serif"; }
     </style>
     <p>the time is <%= currentTime %>.

The CSS generated by connect-fonts is based on the user’s locale and browser. Here’s an example for the ‘en’ localized subset of Open Sans:

/* this is the output with the middleware's ua param set to 'all' */
@font-face {
  font-family: 'Open Sans';
  font-style: normal;
  font-weight: 400;
  src: url('/fonts/en/opensans-regular.eot');
  src: local('Open Sans'),
       local('OpenSans'),
       url('/fonts/en/opensans-regular.eot#') format('embedded-opentype'),
       url('/fonts/en/opensans-regular.woff') format('woff'),
       url('/fonts/en/opensans-regular.ttf') format('truetype'),
       url('/fonts/en/opensans-regular.svg#Open Sans') format('svg');
}

If you don’t want to incur the cost of an extra HTTP request, you can use the connect_fonts.generate_css() method to return this CSS as a string, then insert it into your existing CSS files as part of a build/minification process.

That does it! Our little app is serving up stylish timestamps. This example code is available on github and npm if you want to play with it.

We’ve covered a pre-made font pack, but it’s easy to create your own font packs for paid fonts. There are instructions on the connect-fonts readme.

Wrapping up

Font subsetting can bring huge performance gains to sites that use web fonts; connect-fonts handles a lot of the complexity if you self-host fonts in an internationalized Connect app. If your site isn’t yet internationalized, you can still use connect-fonts to serve up your native subset, and it’ll still generate @font-face CSS and any necessary CORS headers for you, plus you’ll have a smooth upgrade path to internationalize later.

Future directions

Today, connect-fonts handles subsetting based on locale. What if it also stripped out font hinting for platforms that don’t need it (everything other than Windows)? What if it also optionally gzipped fonts and added far-future caching headers? There’s cool work yet to be done. If you’d like to contribute ideas or code, we’d love the help! Grab the source and dive in.

Previous articles in the series

This was part seven in a series with a total of 12 posts about Node.js. The previous ones are:

View full post on Mozilla Hacks – the Web developer blog

VN:F [1.9.22_1171]
Rating: 0.0/10 (0 votes cast)
VN:F [1.9.22_1171]
Rating: 0 (from 0 votes)

Fantastic front-end performance, part 2: caching dynamic content with etagify – A Node.JS Holiday Season, part 6

This is episode 6, out of a total 12, in the A Node.JS Holiday Season series from Mozilla’s Identity team. Today it’s time for the second part about front end performance.

You might know that Connect puts ETags on static content, but not dynamic content. Unfortunately, if you dynamically generate i18n versions of static pages, those pages don’t get caching headers at all–unless you add a build step to pregenerate all pages in all languages. What a lame chore.

Introducing etagify

This article introduces etagify, a Connect middleware that generates ETags on the fly by md5-ing outgoing response bodies, storing the hashes in memory. Etagify lets you skip the build step, improves performance more than you might think (we measured a 9% load time improvement in our tests), and it’s super easy to use:

1. register etagify at startup

myapp = require('express').createServer();
myapp.use(require('etagify')()); // <--- like this.

2. call etagify on routes you want to cache

app.get('/about', function(req, res) {
  res.etagify();  // <--- like that.
  var body = ejs.render(template, options);
  res.send(body);
});

Read on to learn more about etagify: how it works, when to use it, when not to use it, and how to measure your results.

(Need a refresher on ETags and HTTP caching? We’ve put together a cheat sheet to get you back up to speed.)

How etagify works

By focusing on a single, concrete use case, etagify gets the job done in just a hundred lines of code (including documentation). Let’s take a look at the fifteen lines that cover the basics, leaving out Vary header handling edge cases.

There are two parts to consider: hashing outgoing responses & caching the hashes; checking the cache against incoming conditional GETs.

First, here’s where we add to the cache. Comments inline.

// simplified etagify.js internals
 
// start with an empty cache
// example entry: 
//   '/about': { md5: 'fa88257b77...' }
var etags = {};
 
var _end = res.end;
res.end = function(body) {
  var hash = crypto.createHash('md5');
 
  // if the response has a body, hash it
  if (body) { hash.update(body); }
 
  // then add the item to the cache
  etags[req.path] = { md5: hash.digest('hex') };
 
  // back to our regularly-scheduled programming
  _end.apply(res, arguments);
}

Next, here’s how we check against the cache. Again, comments inline.

// the etagify middleware
return function(req, res, next) {
  var cached = etags[req.path]['md5'];
 
  // always add the ETag if we have it
  if (cached) { res.setHeader('ETag', '"' + cached + '"' }
 
  // if the browser sent a conditional GET,
  if (connect.utils.conditionalGET(req)) {
 
    // check if the If-None-Match and ETags are equal
    if (!connect.utils.modified(req, res)) {
 
      // cache hit! browser's version matches cached version.
      // strip out that ETag & bail with a 304 Not Modified.
      res.removeHeader('ETag');
      return connect.utils.notModified(res);        
    }
  }
}

When (and when not) to use etagify

Etagify’s approach is super simple, and it’s a great solution for dynamically-generated pages that don’t change while the server is running, like i18n static pages. However, etagify has some gotchas when dealing with other common use cases:

  • if pages change after being first cached, users will always see the stale, cached version
  • if pages are personalized for each user, two things could happen:
    • if a Vary:cookie header is used to cache users’ individual pages separately, then etagify’s cache will grow without bound
    • if no Vary:cookie header is present, then the first version to enter the cache will be shown to all users

Measuring performance improvements

We didn’t foresee huge performance wins with etagify, because conditional GETs still require an HTTP roundtrip, and avoiding page redownloading only saves the user a few KB (see screenshot). However, etagify is a really simple optimization, so even a small gain would justify including it in our stack.

firebug screen cap showing 2kb savings

We tested etagify’s effects on performance by spinning up a dev instance of Persona on an awsbox, opening up firebug, and taking 50 load time measurements of our ‘about’ page–with and without etagify enabled. (Page load times are a good-enough metric for our use case; you might care more about time till above-the-fold content renders, or the first content hits the page, or the first ad is displayed.)

After gathering raw data, we did some quick statistics to see how much etagify improved performance. We found the mean and standard deviation for both data sets, assuming the measured values were spread out like a bell curve around the averages.

Surprisingly, we found that etagify reduced load time by 9%, from 1.65 (SD = 0.19) to 1.50 (SD = 0.13) seconds. That’s a serious gain for almost no work.

Next, we used the t-test to check the odds that the improvement could be observed without adding etagify at all. Our p-value was less than 0.01, meaning less than 1% chance that randomness could have caused the apparent improvement. We can conclude that the measured improvement is statistically significant.

Here’s a chart of the averaged before and after data:

normal distributions with and without etagify

Bringing it all back home

We think etagify is a tiny bundle of win. Even if it’s not the right tool for your current project, hopefully our approach of (1) writing focused tools to solve just the problem at hand, and (2) measuring just rigorously enough to be sure you’re getting somewhere, gives you inspiration or food for thought.

Previous articles in the series

This was part six in a series with a total of 12 posts about Node.js. The previous ones are:

View full post on Mozilla Hacks – the Web developer blog

VN:F [1.9.22_1171]
Rating: 0.0/10 (0 votes cast)
VN:F [1.9.22_1171]
Rating: 0 (from 0 votes)

Fantastic front-end performance Part 1 – Concatenate, Compress & Cache – A Node.JS Holiday Season, part 4

This is episode 4, out of a total 12, in the A Node.JS Holiday Season series from Mozilla’s Identity team. It’s the first post about how to achieve better front-end performance.

In this part of our “A Node.JS Holiday Season” series we’ll talk about front-end performance and introduce you to tools we’ve built and use in Mozilla to make the Persona front-end be as fast as possible.

We’ll talk about connect-cachify, a tool to automate some of the most important parts of front-end performance.

Before we do that, though, let’s recap quickly what we can do as developers to make our solutions run on the machines of our users as smooth as possible. If you already know all about performance optimisations, feel free to proceed to the end and see how connect-cachify helps automate some of the things you might do by hand right now.

Three Cs of client side performance

The web is full of information related to performance best practices. While many advanced techniques exist to tweak every last millisecond from your site, three basic tools should form the foundation – concatenate, compress and cache.

Concatenation

The goal of concatenation is to minimize the number of requests made to the server. Server requests are costly. The amount of time needed to establish an HTTP connection is sometimes more expensive than the amount of time necessary to transfer the data itself. Every request adds to the overhead that it takes to view your site and can be especially problematic on mobile devices where there is significant connection latency. Have you ever browsed to a shopping site on your mobile phone while connected to the Edge network and grimaced as each image loaded one by one? That is connection latency rearing its head.

SPDY is a new protocol built on top of HTTP that aims to reduce page load time by combining resource requests into a single HTTP connection. Unfortunately, at the present time only recent versions of Firefox, Chrome and Opera support this new protocol.

Combining external resources wherever possible, though old fashioned, works across all browsers and does not degrade with the advent of SPDY. Tools exist to combine the three most common types of external resources – JavaScript, CSS and images.

JavaScript & CSS

A site with more than one external JavaScript inclusion should consider combining the scripts into a single file for production. Browsers have traditionally blocked all other rendering while JavaScript is downloaded and processed. Since each requested JavaScript resource carries with it a certain amount of latency, the following is slower than it needs to be:

  <script src="jquery.min.js"></script>
  <script src="main.js"></script>
  <script src="image-carousel.js"></script>
  <script src="widget.js"></script>

By combining four requests into one, the total amount of time the browser is blocked due to latency will be significantly reduced.

  <script src="main.production.js"></script>

Working with combined JavaScript while still in development can be very difficult so concatenation is usually only done for a production site.

Like JavaScript, individual CSS files should be combined into a single resource for production. The process is the same.

Images

Data URIs and image sprites are the two primary methods that exist to reduce the number of requested images.

data: URI

A data URI is a special form of a URL used to embed images directly into HTML or CSS. Data URIs can be used in either the src attribute of an img tag or as the url value of a background-image in CSS. Because embedded images are base64 encoded, they require more bytes but one less HTTP request than the original external binary image. If the included image is small, the increased byte size is usually more than offset by the reduction in the number of HTTP requests. Neither IE6 nor IE7 support data URIs so know your target audience before using them.

Image sprites

Image sprites are a great alternative whenever a data URI cannot be used. An image sprite is a collection of images combined into a single larger image. Once the images are combined, CSS is used to show only the relevant portion of the sprite. Many tools exist to create a sprite out of a collection of images.

A drawback to sprites comes in the form of maintenance. The addition, removal or modification of an image within the sprite requires a congruent change to the CSS.

Sprite Cow helps you get the background-position, width and height of sprites within a spritesheet as a nice bit of copyable css.

Removing extra bytes – minification, optimization & compression

Combining resources to minimize the number of HTTP requests goes a long way to speeding up a site, but we can still do more. After combining resources, the number of bytes that are transferred to the user should be minimized. Minimizing bytes usually takes the form of minification, optimization and compression.

JavaScript & CSS

JavaScript and CSS are text resources that can be effectively minified. Minification is a process that transforms the original text by eliminating anything that is irrelevant to the browser. Transformations to both JavaScript and CSS start with the removal of comments and extra whitespace. JavaScript minification is much more complex. Some minifiers perform transforms that replace multi-character variable names with a single character, remove language constructs that are not strictly necessary and even go so far as to replace entire statements with shorter equivalent statements.

UglifyJS, YUICompressor and Google Closure Compiler are three popular tools to minify JavaScript.

Two CSS minifiers include YUICompressor and UglifyCSS.

Images

Images frequently contain data that can be removed without affecting its visual quality. Removing these extra bytes is not difficult, but does require specialized image handling tools. Our own Francois Marier has written two blog posts on working with PNGs and with GIFs.

Smush.it from Yahoo! is an online optimization tool. ImageOptim is an equivalent offline tool for OSX – simply drag and drop your images into the tool and it will reduce their size automatically. You don’t need to do anything – ImageOptim simply replaces the original files with the much smaller ones.

If a loss of visual quality is acceptable, re-compressing an image at a higher compression level is an option.

The Server Can Help Too!

Even after combining and minifying resources, there is more. Almost all servers and browsers support HTTP compression. The two most popular compression schemes are deflate and gzip. Both of these make use of efficient compression algorithms to reduce the number of bytes before they ever leave the server.

Caching

Concatenation and compression help first time visitors to our sites. The third C, caching, helps visitors that return. A user who returns to our site should not have to re-download all of the resources again. HTTP provides two widely adopted mechanisms to make this happen, cache headers and ETags.

Cache headers come in two forms and are suitable for static resources that change infrequently, if ever. The two header options are Expires and Cache-Control: max-age. The Expires header specifies the date after which the resource must be re-requested. max-age specifies how many seconds the resource is valid for. If a resource has a cache header, the browser will only re-request that resource once the cache expiration date has passed.

An ETag is essentially a resource version tag that is used to validate whether the local version of a resource is the same as the server’s version. An ETag is suitable for dynamic content or content can change at any time. When a resource has an ETag, it says to the browser “Check the server to see if the version is the same, if it is, use the version you already have.” Because an ETag requires interaction with the server, it is not as efficient as a fully cached resource.

Cache-busting

The advantage to using time/date based cache-control headers instead of ETags is that resources are only re-requested once the cache has expired. This is also its biggest drawback. What happens if a resource changes? The cache has to somehow be busted.

Cache-busting is usually done by adding a version number to the resource URL. Any change to a resources URL causes a cache-miss which in turn causes the resource to be re-downloaded.

For example if http://example.com/logo.png has a cache header set to expire in one year but the logo changes, users who have already downloaded the logo will only see the update a year from now. This can be fixed by adding some sort of version identifier to the URL.

http://example.com/v8125/logo.png

or

http://example.com/logo.png?v8125

When the logo is updated, a new version is used meaning the logo will be re-requested.

http://example.com/v8126/logo.png

or

http://example.com/logo.png?v8126

Connect-cachify – A NodeJS library to serve concatenated and cached resources

Connect-cachify is a NodeJS middleware developed by Mozilla that makes it easy to serve up properly concatenated and cached resources.

While in production mode, connect-cachify serves up pre-generated production resources with a cache expiration of one year. If not in production mode, individual development resources are served instead, making debugging easy. Connect-cachify does not perform concatenation and minification itself but instead relies on you to do this in your project’s build script.

Configuration of connect-cachify is done through the setup function. Setup takes two parameters, assets and options. assets is a dictionary of production to development resources. Each production resource maps to a list its individual development resources.

  • options is an optional dictionary that can take the following values:
  • prefix – String to prepend to the hash in links. (Default: none)
  • production – Boolean indicating whether to serve development or production resources. (Defaults to true)
  • root – The fully qualified path from which static resources are served. This is the same value that you’d send to the static middleware. (Default: ‘.’)

Example of connect-cachify in action

First, let’s assume we have a simple HTML file we wish to use with connect-cachify. Our HTML file includes three CSS resources as well as three Javascript resources.

...
<head>
  <title>Dashboard: Hamsters of North America</title>
  <link rel="stylesheet" type="text/css" href="/css/reset.css" />
  <link rel="stylesheet" type="text/css" href="/css/common.css" />
  <link rel="stylesheet" type="text/css" href="/css/dashboard.css" />
</head>
<body>
  ...
  <script type="text/javascript" src="/js/lib/jquery.js"></script>
  <script type="text/javascript" src="/js/magick.js"></script>
  <script type="text/javascript" src="/js/laughter.js"></script>
</body>
...

Set up the middleware

Next, include the connect-cachify library in your NodeJS server. Create your production to development resource map and configure the middleware.

...
// Include connect-cachify
const cachify = require('connect-cachify');
 
// Create a map of production to development resources
var assets = {
  "/js/main.min.js": [
    '/js/lib/jquery.js',
    '/js/magick.js',
    '/js/laughter.js'
  ],
  "/css/dashboard.min.css": [
    '/css/reset.css',
    '/css/common.css',
    '/css/dashboard.css'
  ]
};
 
// Hook up the connect-cachify middleware
app.use(cachify.setup(assets, {
  root: __dirname,
  production: your_config['use_minified_assets'],
}));
...

To keep code DRY, the asset map can be externalized into its own file and used as configuration to both connect-cachify and your build script.

Update your templates to use cachify

Finally, your templates must be updated to indicate where production JavaScript and CSS should be included. JavaScript is included using the “cachify_js” helper whereas CSS uses the “cachify_css” helper.

...
<head>
  <title>Dashboard: Hamsters of North America</title>
  <%- cachify_css('/css/dashboard.min.css') %>
</head>
<body>
  ...
  <%- cachify_js('/js/main.min.js') %>
</body>
...

Connect-cachified output

If the production flag is set to false in the configuration options, connect-cachify will generate three link tags and three script tags, exactly as in the original. However, if the production flag is set to true, only one of each tag will be generated. The URL in each tag will have the MD5 hash of the production resource prepended onto its URL. This is used for cache-busting. When the contents of the production resource change, its hash also changes, effectively breaking the cache.

...
<head>
  <title>Dashboard: Hamsters of North America</title>
  <link rel="stylesheet" type="text/css" href="/v/2abdd020a6/css/dashboard.min.css" />
</head>
<body>
  ...
  <script type="text/javascript" src="/v/acdd2ab372/js/main.min.js"></script>
</body>
...

That’s all there is to setting up connect-cachify.

Conclusion

There are a lot of easy wins when looking to speed up a site. By going back to basics and using the three Cs – concatenation, compression and caching – you will go a long way towards improving the load time of your site and the experience for your users. Connect-cachify helps with concatenation and caching in your NodeJS apps but there is still more we can do. In the next installment of A NodeJS Holiday Season, we will look at how to generate dynamic content and make use of ETagify to still serve maximally cacheable resources.

Previous articles in the series

This was part four in a series with a total of 12 posts about Node.js. The previous ones are:

View full post on Mozilla Hacks – the Web developer blog

VN:F [1.9.22_1171]
Rating: 0.0/10 (0 votes cast)
VN:F [1.9.22_1171]
Rating: 0 (from 0 votes)