Rebecca Murphey

Last updated

Building for HTTP/2

This post is really old! I've kept it around because it may still be interesting, but many things may be out of date.

Earlier this year, I got the chance to speak with Google's Ilya Grigorik about HTTP/2 for the 1.10 episode of the TTL Podcast. It was a great primer for me on how HTTP/2 works and what it means for how we build the web, but it wasn't until more recently that I started to think about what it means for how we build the web — that is, how we generate and deploy the HTML, CSS, and JS that power web applications.

If you're not familiar with HTTP/2, the basics are simultaneously simple and mind-boggling. Whereas its predecessors allowed each connection to a server to serve only one request at a time, HTTP/2 allows a connection to serve multiple requests simultaneously. A connection can also be used for a server to push a resource to a client — a protocol-level replacement for the technique we currently call “inlining.”

This is everything-you-thought-you-knew-is-wrong kind of stuff. In an HTTP/2 world, there are few benefits to concatenating a bunch of JS files together, and in many cases the practice will be actively harmful. Domain sharding becomes an anti-pattern. Throwing a bunch of <script> tags in your HTML is suddenly not a laughably terrible idea. Inlining of resources is a thing of the past. Browser caching — and cache busting — can occur on a per-module basis.

What does this mean for how we build and deploy applications? Let's start by looking at the state of the art in client-side application deployment prior to HTTP/2.

Deploying JavaScript Applications (2013) #

In March of 2013, Alex Sexton wrote Deploying JavaScript Applications, and it's what I consider to be the canonical post on the topic for sites and apps that include more than about 50K of client-side code.

In his post, Alex describes a deployment that uses a "scout" approach: a small bit of code, included directly in the HTML or loaded via <script> tag.

The scout file exists to balance the desire for application resources to be highly cacheable vs. the need for changes to those resources to take effect quickly.

To meet that goal, the scout needs a short cache time when it's a file; if the scout is in the HTML, then the HTML itself needs a short cache time. The scout contains information about the location of the file(s) that provide the current version of the application, and the code necessary to load those files.

Files loaded by the scout can have extremely long cache times because the scout loads resources from versioned URLs: when a resource is updated, it is hosted at a new URL, and the scout is updated to load the resource from that new URL.

Why a scout approach rather than just loading the versioned files using <script> tags directly from the HTML? The scout technique lets you deploy changes to your JavaScript application without requiring a re-deploy of the server-side application. (In an ideal world this might not seem valuable, but in the real world, it often is.) When the scout is served separately from the HTML, it also allows for a different caching strategy for the HTML.

In this system, it's typical that the scout would load one or two JavaScript files that were generated by combining the modules needed for the initial state of the application. More code might be loaded later to support additional application behavior; again, that code would typically comprise a set of modules shipped in a single file.

There are a few shortcomings inherent to this approach, which are difficult to overcome without upsetting the balance between cacheability and changeability:

Adding HTTP/2 to the mix — that is, flipping the switch that gets your server to start speaking HTTP/2 to browsers that understand it — has a nominal positive impact on the performance of an app crafted for maximum performance on HTTP/1. Indeed, the applications most likely to see big improvements without big changes are applications whose deployments were poorly designed in the first place.

To see performance gains in a well-engineered deployment, we'll have to re-engineer the deployment itself.

Splitting it up #

One of the most obvious opportunities is presented by HTTP/2's ability to handle multiple requests over the same connection. Rather than shipping a single large application file over the wire, what if we tell the scout to load the individual modules that make up the application? We would no longer have to invalidate the cache for the whole application every time we make a change.

A few reasons come to mind why this might be a bad idea.

The first is the concern that compression might suffer if shipping modules individually. As it turns out, though, combining multiple modules into a single file results in only slightly better compression than if the modules are compressed individually. For example, compressing a file containing minified versions of jQuery, Underscore, and Backbone results in 42,186-byte file; compressing each minified file individually results in a combined size of 42,975 bytes. The difference is 789 bytes -- barely meaningful.

Other second concern may be more legitimate: our server or CDN may be unhappy about serving one request per module; and it may be unduly complex to ship a single module per file, especially since any given request might fail for whatever reason. For the sake of discussion, we'll assume that it's reasonable to do some grouping of modules into individual files.

How to group those modules is up for debate. One strategy could be to group files according to their likelihood of changing, recognizing that library and framework modules don't change often, while application modules do. Another strategy would be to group files associated with a unit of useful functionality, though this leaves us needing a way to deliver code that's shared across units of functionality.

At Bazaarvoice, we solve this concern via a lightweight require/define system that ships in the scout file, allowing us to share vendor files such as jQuery and Backbone across applications. An application can express a dependency on a vendor file using NAMESPACE.require(), and vendor files declare themselves using NAMESPACE.define(). Once a vendor file has been defined, other modules on the page have access to it immediately via NAMESPACE.require().

Versioning #

For HTTP/1.1-friendly builds, we always increment the version of the built application file, and embed a URL pointing to that new version in the scout file. We do this because it is essentially guaranteed that the contents of the application file have changed whenever we do a new build -- otherwise there would be no reason for the build.

For HTTP/2-friendly builds, we’re generating many smaller files; we only want to increment their version when something has changed.

For example, imagine a build that generates vendor-v1.js and application-v1.js; it also generates a scout that loads these two files. We then make a change to an application file, and we do another build, creating vendor-v2.js and application-v2.js. However, no vendor files have changed; our scout should now load to application-v2.js but still load vendor-v1.js. If our scout points to vendor-v2.js, we lose the benefit of being able to cache smaller pieces of our code.

This can be solved by using hashes of the file contents rather than version numbers: vendor-d41d8cd98f.js. If a file has not changed, its hash will remain the same. (Notably, inconsequential changes will change the hash -- for example, a new copyright comment that is inserted post-minification.) Plenty of build strategies already use content hashes for versioning; however, many still use integers, dates, or commit hashes, which change even when the contents of a file have not.

Given files whose names include a hash, our scout can include a manifest that prescribes the file to load for a given resource. The manifest would be generated by the build after all of the resources were generated.

module.exports = {
baseUrl : 'https://mysite.com/static/',
resources : {
vendor : 'vendor-d41d8cd98f.js',
application : 'application-a32e3ec23d.js'
}
};

Push: Because you downloaded scout.js, you might also like ... #

Another exciting opportunity in an HTTP/2 world is the ability to push a cascade of resources.

The first push opportunity is the scout itself: for sites and applications that currently ship the scout inlined in the initial HTML payload, server push affords an opportunity to send the scout as a separate resource when the initial HTML is requested.

There’s an interesting dilemma here: If the browser already has the resource cached, and the cache is still valid, it doesn’t need the server to push the resource. Currently, though, there’s no way for the browser to communicate its cache contents to the server. A browser can decline a push, but the server may have already started to send it. We’ve basically introduced a new tradeoff: server push can get the resource to the browser quickly, but we waste bandwidth if the browser doesn’t need it.

As discussed at the link above, a smart server could use session information to determine when to push -- for example, if the page is reloaded within a resource’s cache time, there is no need to re-push that resource to the same session -- but this makes push state-dependent, a frightening prospect if we hope to use CDNs to ensure efficient asset delivery.

Assuming we've generated a manifest as described above, we have the option of going a step further: we can separate the manifest and the scout, allowing the scout to have a far longer cache time than in a pre-HTTP/2 world. This is possible because the thing that typically changes about the scout is the version of the resources it loads, and it makes the most sense on a site where there are different payloads for different pages or users. For applications that previously included the scout in HTML, we can push the scout and the manifest, and have the scout request the manifest; for applications that loaded the scout as its own JS file, we can push the manifest when the scout file is loaded and, again, have the scout request the manifest.

This approach also makes a further case for a standardized scout: application-specific configuration can be shipped in the manifest, and a standardized scout can be shared across applications. This scout could be a file loaded via a script tag, where the script tag itself provides information about the application manifest to use:

<script src="/static/shared/js/scout.js"
data-manifest="/static/apps/myapp/manifest.js">
</script>

The manifest contains information about the other resources that the scout will request, and can even be used by the server to determine what to push alongside the HTML.

A manifest could provide these instructions:

module.exports = {
baseUrl : 'https://mysite.com/static/',
resources : {
vendor : {
version : 'vendor-d41d8cd98f.js',
pushWith : [ 'scout' ]
},
application : {
version : 'application-a32e3ec23d.js',
pushWith : [ 'scout' ]
},
secondary : {
version : 'secondary-e43b8ad12f.js',
pushWith : [ ]
}
}
};

Processing this manifest would require intelligence on the part of the CDN; it may be necessary to replace s3 storage with an actual server that is capable of making these decisions, fronted by a CDN that can intelligently relay responses that include server push.

The elephants in the room #

There are two notable challenges to the rapid transition to an HTTP/2 world: the continued existence of legacy browsers, especially on mobile; and the requirement that HTTP/2 connections be conducted over TLS. Thankfully, the latter provides a reasonable opportunity to address the former. Let's, then, talk about the TLS requirement first.

HTTP/2 is a new protocol, and as such, it is greatly confusing to a large segment of the existing internet: proxies, antivirus software, and the like. During the development of HTTP/2 and SPDY before it, engineers observed that traffic that was transported on an insecure connection would frequently fail. The reason? The proxies, the antivirus software, and all the rest had certain expectations of HTTP traffic; HTTP/2 violated those expectations, and so HTTP/2 traffic was considered unsafe. The software that thwarted insecure HTTP/2 traffic didn't have the ability to inspect secure traffic, and so HTTP/2 traffic over a secure connection passed through just fine. Thus was born the requirement — which is a browser implementation detail, and not part of the HTTP/2 spec — that HTTP/2 web communication be conducted using TLS.

The Let's Encrypt project aims to eliminate the high cost of obtaining the certificate that enables secure HTTP communication; there will still be technical hurdles to using that certificate, but those should be surmountable for anyone who cares enough to engineer a performant HTTP/2 deployment.

In order for a browser and a server to communicate using HTTP/2, the browser and the server must first agree that they can. The TLS handshake that enables secure communication turns out to be the ideal time to negotiate the communication protocol, as well: no additional round trip is required for the negotiation.

When a server is handling a request, it knows whether the browser understands HTTP/2; we can use this information to shape our payload. We can send a legacy browser an HTML file that includes an inlined scout file, and that inlined scout file can include the manifest. The manifest can provide information about how to support legacy browsers:

module.exports = {
baseUrl : 'https://mysite.com/static/',
resources : {
// ...
},
legacyResources : {
legacyMain : {
initialLoad : true,
version : 'legacy-main-c312efa43e.js'
},
legacySecondary : {
version : 'legacy-secondary-a22cf1e2af.js'
}
}
};

For Consideration: HTTP/2-friendly deployments with HTTP/1.1 support #

Putting the pieces together, we arrive at a deployment process that does the following:

The versioning and caching of the resources would be as follows:

* In applications that a) control the initial HTML payload, and b) only use the scout to load other resources, it may not make sense to have a separate scout; it might be sufficient to just load those resources via <script> and <link> tags in the HTML itself. This approach isn't viable for applications that do not control the initial HTML payload, such as third-party applications.

Reality check #

In several places so far, I’ve talked about the need for a server to make decisions about which resources it delivers, and when and how it delivers them. As I alluded to earlier, this could be profoundly challenging for CDNs, which traditionally simply receive a request and return a single resource in response. It also suggests the need for close collaboration between client and server development teams, and an increased knowledge of server-side technology for client-side developers.

CDN support of HTTP/2 in general is rather disappointing, with some major vendors providing nothing more than vague timelines for non-specific support.

As of this writing, I'm unaware of CDNs that support any notion of server push, but I'd be happy to find I am ill-informed. Ideally, CDNs need to provide applications with the ability to express how static assets relate to each other -- a task complicated by the fact that those relationships may be situational, such as in the case where an application doesn't want to push an asset that was just pushed to the same client 10 seconds before. One-size-fits-all push could be accomplished by setting a header on a file, indicating that other files should be pushed alongside it, but that doesn't allow for expressing more nuanced rules.

Even for applications that just want to split their payload into smaller files to take advantage of HTTP/2, and that don't intend to use server push, there is still a gap when it comes to providing a positive experience for HTTP/1.1 clients. CDNs need to surface the ability to change a response not just based on the URL that is requested, but the protocol of the request. Without this ability, we'll be stuck having to choose which protocol to support.

There is also work to be done on tooling, especially if we want to support HTTP/2 without significantly degrading the experience for legacy browsers. Ideally, our build tooling would figure out the optimal combination of files for us, with a knowledge of how the application was bundled previously so as not to squander past caching.

The developer story for HTTP/2 also leaves a lot to be desired as of this writing. Front-end developers are among the most likely in an organization to advocate for this new technology, but my experiences over a few weeks of learning about HTTP/2 suggest that the effort required to set up even a local environment will stretch the comfort zone for many. With a working local environment in hand, the tools to understand the differences between HTTP/2 and HTTP/1 behavior are limited and often confusing. Chrome presents information in its network tab that seems to conflict with the wall of text in its net-internals tool, especially when it comes to server push . Charles Proxy doesn't yet speak HTTP/2. Firefox shows pushed resources as an entry in the network tab, but they appear as though they were never received. nghttp2 provides great insight into how an HTTP/2 server is behaving, but it doesn't speak HTTP/1.1, so you can't use it to do comparisons. Measuring performance using a tool like WebPagetest requires a real certificate, which you may not have handy if you're just trying to experiment.

Alex wrote his 2013 post to document the product of years of experience in creating performant HTTP/1.1 deployments. HTTP/2 means we need to rethink everything we know about shipping applications to the web, and while the building blocks are there, there's still much to figure out about how we'll use them; the "right" answers are, in many cases, still TBD while we wait for vendors to act.

Further Reading #

I've been bookmarking useful HTTP/2 resources as I come across them.

Thanks #

Thanks to the many folks who have talked to me about the ideas in this post, but especially to Lon Ingram, Jake Archibald, and Andy Davies.

Read more posts in the archive.