There’s a lot of ground to cover, so let’s ignore module systems, minification, code maintainability, and just focus on the problem of delivery performance.
For the sake of discussion, let’s use an incredibly simplified version of Khan Academy: there are video pages, exercise pages, and a home page.
Solution 1: One Script Tag Per Source File
The easiest and most obvious solution looks like this:
<!doctype html> <html> <head> <title>Video Page</title> </head> <body> <!-- Server rendered video page content goes here ... -> <script src="/js/jquery.js"><script> <script src="/js/underscore.js"></script> <script src="/js/react.js"></script> <script src="/js/sidebar.js"></script> <script src="/js/video-player.js"></script> <script src="/js/discussion.js"></script> </body> </html>
And then on the homepage, you’d have:
<script src="/js/jquery.js"><script> <script src="/js/underscore.js"></script> <script src="/js/homepage.js"></script>
This is by far the simplest solution because it requires no fancy build system like Grunt or gulp - you can just serve your JS directly from disk. You’re going to want to have a build step anyway to do minification though, so this isn’t a huge advantage.
On the upside, this solution gives excellent cache performance. Each one of the files can be separately cached, so changing one of your JS files will require users to download only the things that changed since the last time they were there1. This is especially attractive because the biggest files, like jQuery, are unlikely to change frequently.
Even better, if you need different JS files on different pages on your site, you
only have to download the bits of that page that you didn’t already get from
visiting other pages. For instance, if you went from the homepage to the video
page, you’d already have
underscore.js in your cache.
On the major downside, this is a ton of network traffic. If you have 10 JS script files on your page, then you’re firing 10 HTTP requests to get those 10 files from server to browser. Each of these has overhead, and requires a round trip to the server. Establishing connections can be slow, and each connection undergoes TCP Slow-start, meaning that it doesn’t reach full speed until after a few round trips2. Downloading ten 20kB files is a great deal slower than downloading one 200kB file over HTTP because of this.
When you’re sending any plaintext assets (HTML, JS, CSS, etc.) from the server, you should be compressing those assets to send fewer bytes over the network. gzip is the normal solution. By serving each file separately from the server, we’re losing out on potential compression benefits.
As a test, I compared gzipping 60 JS files from one of Khan Academy’s folders and then concatenating the results vs. concatenating and gzipping after.
Concatenate then compress: ~82kB
$ cat *.js | gzip -c | wc -c 84015
Compress then concatenate: ~98kB
$ for i in *.js; do gzip -c $i; done | wc -c 99997
Summary of One Script Tag Per Source File:
- Downloads only what is needed
- High cache hit rate between page types
- High cache hit rate between deploys
- Many HTTP round trips
- Poor Compression
Solution 2: One Big JS File
When you see poor compression and the many HTTP round trips problems, the next most obvious thing might be to concatenate absolutely all of your JS together. Diagramatically, this would look like this:
And then every single one of your HTML files would have this:
While this does offer better compression and fewer HTTP round trips, we have two new problems. The first is that we’re now downloading potentially WAY more stuff on a page than we actually need.
For instance, to load up the homepage of the site, you now need to download all
of the source of
video-player.js even though the homepage uses
none of that!
The second problem is that your cache hit rate between deploys drops to zero.
Since everything is in one big file now, if you make a 1 line change to some
homepage.js and deploy, everyone has to re-download all of
video-player.js and everything else.
On the upside of cache performance, once you hit one page and download the stuff you need, as you switch to a different page, you already have all the JS you need cached in your browser.
- Perfect cache hit rate between page types
- One HTTP round trip
- Excellent compression
- Forces users to download a lot of things they don’t need
- Zero cache hit rate between deploys
Solution 3: One JS File Per Page
Since downloading the entire site’s JS in one shot downloads way too much stuff, why don’t we just download one file that contains all the things we need on a per-page basis? That would look like this:
Then the homepage HTML would have:
And the video page would have:
This solution fixes the “downloading stuff we don’t need” problem, and also
improves cache performance between deploys. It’s much better than the “one big
file” solution because if you change
homepage.js and deploy, your users won’t
need to re-download
e-package.js since their contents
haven’t changed. You do still have to redownload all of
which is sort of a bummer, because it means redownloading the big files like
The worse problem is that your cache hit rate betwen pages is zero. Even though
every page uses
jquery.js, it’s concatenated into each
-package.js file, so
your browser has no idea that it might have it already. This means your browser
will download up to 3 copies of
jquery.js: one in each of
- Downloads only what is needed
- One HTTP round trip per page
- Good compression
- Coarse caching between deploys
- Zero cache hit rate between page types
Solution 4: Many Concatenated JS Files Per Page
As with many things in life, the solution is a compromise. We can’t get all of the benefits of all of the above solutions with none of the problems, but we can get rid of the worst problems to get something pretty good.
To provide finer-grained caching and improve cache hit rate between page types without massively inflating the number of HTTP connections, we go for something between “one script tag per file” and “one file per page”, and end up with this:
Once you have a system like this, you can try to balance all of the pros and
cons discussed in previous solutions. The above diagram shows a solution where
each page downloads only exactly what it needs, which is good, but you’ll notice
sidebar.js are packaged together.
react.js is pretty
big, so ideally I’d like to be able to change
sidebar.js without forcing my
users to redownload
react.js. I also might want to further minimize the number
of requests on the video and exercises page, and arrive at this:
So now we have fewer requests, and we won’t break the cache on
changing something in
sidebar.js, but we’re now downloading
react.js on the
homepage, and downloading exercise-specific things that we don’t need on the
video page and vice versa.
So of these two packaging policies is better? The answer is (unsurprisingly)
that it depends. How likely is sidebar.js to change? How much compression
benefit do we get from putting
react.js in the same
Khan Academy has been using a variant of Solution 4 since before my first internship there in 2012, so my job is now mostly concerned with how to optimize our packages3.
The problem now comes down to two things: which
.js files do I concatenate
together into each
-package.js, and which
-package.js files do I load from
each HTML page4?
Ultimately the goal is to minimize the average time the user spends between receiving the HTML response from the server and when enough JS runs so that they can do the thing they want to do on the page.
Even if you had those timings, turning that into actionable modifications to your file concatenation/package loading policy would be pretty difficult. A more pragmatic approach is to just have hitcounts for each different HTML page.
Once you have hitcounts for each HTML page, and you know the set of source JS files (“source” as in “before concatenation”), you can try to figure out how to move source files between packages in order to reduce the total aggregate number of bytes users are downloading in a given day5.
At Khan Academy, we’re just now delving into how to do automatic optimization of our packages, so we don’t have production-proven results to justify which metrics and algorithms to use to perform these optimizations, but I’ll be sure to report back when we do.
For reasons I won’t go into in this post, Khan Academy uses our own in-house system for both packaging and specifying inter-file dependencies, but plenty of good open source tools exist that allow you to control how your files get concatenated together.
The three most battle tested I’m aware of:
- webpack with CommonsChunkPlugin would be my personal choice for a
new project. webpack tries to be unopinionated and pragmatic, supporting both
synchronous node style
require()and AMD style. It’s being used in production at Instragram. Pete Hunt has a guide up on how it’s used in production at Instagram: webpack-howto
- Browserify with factor-bundle. Browserify uses node style
require(), and factor-bundle is the bit that lets you pull out common portions to be loaded separately.
- RequireJS with the RequireJS Optimizer. There’s a specific example for optimizing for multi-page apps: example-multipage.
- Failure to properly cache bust static resources can lead to bugs where users are running a version of your JS from a previous deploy because they have it cached in their browser. Overly aggressive cache busting (e.g. busting everything on every deploy) forces users to download things they already have. A good solution is to hash all of your static assets when you upload them and include that hash in the filename. That way, users only download things that actually did change since the last time they visited the website.
- While browsers will re-use TCP connections to make multiple HTTP requests, they still open multiple TCP connections when you request many resources to allow downloading things in parallel. Within a single connection, downloading one big resource will block the download of a smaller resource because the requests on a connection are entirely serialized. SPDY solves this problem by allowing multiple streams across a single TCP connection. [return]
- Before I can do that though, I need to know when it’s safe to move a file between packages, which requires us to have a reliable dependency graph between source files, which has been the bulk of the actual work I’ve done to date since working on the Computer Science Platform. [return]
- There’s also the question of how we load those packages (inline script tags, script with
- Interestingly, this is a variant of the weighted set cover problem, except we also control the elements of the sets! [return]