On resource bundling and compression dictionaries
# Not a bundle of joy?
Folks in the web development community have recently started talking about trying to get rid of bundling as part of the deployment process.
It’s almost as if people don’t like to add extra layers of complexity to their build processes.
At least some developers seem to want to just Use The Platform™ and ship ES modules to the browser, the same way they write them. And those developers are right. That would be neat!! And it’s great to see that it’s working in some cases.
Unfortunately, despite progress on that front, shipping individual ES modules to the browser is still very likely to be slower than bundling them.
# Why bundle?
In the beginning, we had HTTP/1.0 and HTTP/1.1.
We had a limited number of connections over which requests could be sent, which resulted in latency-dependent delays related to our resources. The more resources your web page had, the slower it was to load. Latency, rather than bandwidth, was the limiting factor.
The solution of the web performance community to that was to bundle resources. JS and CSS was concatenated into larger JS and CSS files. Images were thrown onto image sprites. All in order to reduce the number of resources.
And then we got SPDY and HTTP/2 (and HTTP/3) which promised to fix all that. The protocol runs on a single connection, and the limit on the number of requests that can be sent in a single RTT is very high (about a 100 requests by default). Problem solved!!
As the title of this section may imply, the problem was not solved..
HTTP/2+ solved the network latency issues that multiple resources were causing us, but that didn’t solve everything.
In particular:
- HTTP/2 doesn’t help us with discovery. If a certain resource is late-discovered by the browser, the browser will request it later.
- HTTP/2+ doesn’t enable us to extend a compression window beyond a single resource. What that means in practice is that the compression ratio of a lot of small files is significantly worse than the compression ratio of fewer, larger files.
- There still remains an inherent per-request cost in browsers. Each request adds a certain amount of delay. (typically on the order of a few milliseconds)
At the same time, bundling in its current form is also suboptimal. The browser can’t start executing any of the content the bundle contains until the entire bundle was downloaded.
For example, let’s say you have two different widgets on your page. One is responsible for the interactivity of what you want users to be able to do (e.g. a “buy now” button). The other is responsible for some auxiliary functionality (e.g. analytics or prefetching resources for future navigations).
We can probably agree that user-facing interactivity is a higher priority than the auxiliary functionality.
But bundling those two widgets together will inherently slow down the higher priority widget. Even if we load both with high priority, the interactivity widget will have to wait until the auxiliary one also finished downloading and parsing before it can start executing.
In theory, moving to ES modules can avoid this issue, as long as the modules don’t depend on each other. For modules that do depend on each other, we could have solved this by having leaf modules start execution while the modules that depend on them are still loading. In practice, that’s not how ES module loading works, and they need to all be fully loaded and parsed before any of them runs. We would also need to enable microtasks to run between modules if we wanted them to not create responsiveness issues. But I digress..
Another issue that bundling introduces is that it harms caching granularity. Multiple small resources that may change at different times are all bundled together, with a single caching lifetime. And as soon as any of the smaller modules changes, the entire bundle gets invalidated.
# How to bundle?
Let’s say we have a website with 3 different pages, each one of them relying on different JS modules.
Each JS module is imported from different entry points, or from modules that are imported by the different entry points. We represent that in the graph by including three base colors for the entry points, and representing the dependencies on each module by a (rough) combination of these base colors.
We also have modules with gray backgrounds, to represent third-party modules, that are unlikely to change very often.
What’s the best way to split these different modules into different bundles?
- For first load performance, it’d be best if each module's dependencies were in a single bundle, but a small number of bundles is also fine, especially if we flatten the discovery process (e.g. using
<link rel=modulepreload>
). - For caching benefits across pages, it’d be best if each color was in a separate bundle
- For caching over time, it’d be good to cluster the bundles up according to change frequency (e.g. active development code vs. stable library code)
- Priorities can also change what we bundle. For example, dynamically imported modules that are loaded later on in the page may need to be split apart.
- We may want to impose a minimum size for bundles, and avoid very small bundles (for compression ratio reasons) at some cost to caching granularity loss, and while risking loading them unnecessarily on some pages.
Taking some of the above principles into account gives us the following split:
Now what would happen if images.mjs
added an import to popular_library.mjs
? That would move that library from the "green" category to the “black” one (the modules that all pages rely on), and change the semantics of the relevant bundle.
Now imagine that the typical complex web app has hundreds if not thousands of module dependencies with dozens of entry points, and imagine what kinds of semantic drift can happen over time.
# What’s “semantic drift”??
We’ll define “bundle semantic drift” as the phenomena we described above - where web app’s bundles can change their semantics over time, and essentially represent different groups of modules.
We can imagine two kinds of drift.
# Lateral semantic drift
That kind of drift happens when dependencies between modules change, resulting in a module “changing color”. (e.g. popular_library.mjs
in the example above)
The implications are that if the user has two cached bundles that contain all the modules of a certain set, a single module moving from one bundle to another (e.g. because another module started depending on it) would invalidate both bundles, and cause users to redownload both of them, with all the modules they contain.
# Vertical semantic drift
That happens when a new entry point is added to the graph, effectively “adding another color”, and hence changing the dependency graph. That can change the modules contained in many of the existing bundles, as well as create new ones.
# Didn’t you have “compression dictionaries” in the title??
OK, so how’s all that related to compression dictionaries?
Compression dictionaries’ static resource flow enables us to only deliver a certain bundle once, and then when the user fetches the same bundle in the future, use the previous version as a dictionary in order to compress the current version.
That effectively means we only deliver the difference between the two versions!! The result is ridiculously impressive compression ratios. This approach also means that delivering the dictionaries costs us nothing, as they are resources that the user needs anyway.
That effectively means we can ignore the caching lifetime heuristics when drawing bundle boundaries, because they matter significantly less, at least from a delivery perspective. If some of the resources in the bundle change while others don’t, our users only “pay” the download price for the bits that changed.
So in our example, we could draw the bundle boundaries as something like that:
Where that theory fails is when we’re talking about code caching in the browser, which is currently working on a per-resource granularity. But one can imagine this changes as browsers improve their code caching granularity. (and e.g. make it per-function or per-module if we had a way to have a bundle of modules)
# Compression dictionaries and semantic drift
The problem is that compression dictionaries are extremely vulnerable to semantic drift, especially when the bundle’s name is related to which modules it contains.
When that’s the case (which is often), any semantic drift results in a bundle name change.
That has significant (negative) implications on compression dictionaries due to their matching mechanism, which is URLPattern based. If the URL changes in unpredictable ways, currently served bundles will no longer match the browser’s future requests of the equivalent bundles. That means that the browser would not advertise them as an available dictionary, so we won’t be able to use them for compression.
All that to say that in order to properly use compression dictionaries with JS bundles, we’d need (roughly) stable bundle semantics, and stable bundle URLs over time. That is, if we have a bundle filename that looks roughly like bundlename-contenthash.js
, the bundlename
part would need to remain stable over time, even if the hash changes on every release.
# Stable naming patterns over time
So we need bundles that are “more or less the same” to maintain the same name pattern over time, despite a slight drift in their semantics.
When we name bundles based on the modules they contain, that’s not what happens. We get a drift in the names every time there’s a slight drift in the bundle semantics.
A different approach that eliminates that issue with lateral drift is to name bundles based on the entry points that depend on them. Essentially in our examples, we would name the bundles based on their color.
Now if a module moved from one color to another, each bundle’s content would change (and we’d have to redeliver that specific module’s bytes), but neither bundle’s filename would change, so most of the bytes can still be derived from the cached versions of those past-bundles, and won’t be re-delivered.
That’s great, but doesn’t really help us with vertical drift. If a new entry point was added to the mix, that could “change the color” of a lot of modules and cause a significant drift for many bundles.
One potential solution there could be to only consider a subset of the entry points when determining bundle names, and by default when new entry points are added, we don’t consider them for naming existing bundles (that already have other entry points that depend on them).
As adding and removing entry points is presumably relatively rare, it can take quite a while until we have complex dependencies between bundles that are interdependent on entry points that are not considered for naming.
If and when that happens, we can expand the subset of entry points considered for naming. That would result in lower compression ratios the next time users visit.
# Conclusion
Compression dictionaries have huge potential for reducing network overhead, but in order to be able to successfully deploy them with JS bundles, we need stable bundle naming patterns over time.
Luckily for us, modern bundlers provide the necessary controls to achieve that. We have to make use of these controls, in order to ensure that dictionary compression remains effective as our web apps evolve and their bundle semantics drift.
Huge thanks to Pat Meenan, Ryan Townsend and Jake Archibald for reviewing and providing great feedback on this post. (And extra thanks to Jake for helping me figure out how to load the SVG diagrams. This stuff shouldn't be that hard..)