HTTP/2 is out! CDNs are hot!
Perhaps you've heard of HTTP/2 and its Asset Multiplexing and the benefits it can bring to your project and allowing more assets to be loaded at once reducing the need for massive asset bundles and thus reducing cache churn.
The above is all true. HTTP/2 has done wonders for asset loading. However, HTTP/2 isn't a catch all solution that lets you ditch the current set of frontend bundlers and use CDN only solutions.
However, the problem is you lose out on a number of efficiencies that can be afforded by using a frontend bundler. To do this exercise, we'll be looking at Shoelace.
Shoelace is an open-source project that leverages web-components and CSS variables to create a coherent design system for your web application. This case study isn't about Shoelace is or is not doing, but merely used as an example of where CDNs fall down. Also, you should use Shoelace, it is a pretty cool project!
Lets get cracking
Shoelace's easiest installation path is by importing from a JS CDN like jsdelivr.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.78/dist/themes/light.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.78/dist/shoelace.js"></script>
Let's look at the network graph this creates.
Without diving too deep, what happens is Shoelace ships a base "module" that then uses a number of shared chunks generated by ESBuild. So when <script src="${cdn}@{versionNumber}/dist/shoelace.js"
is requested, then it requests the chunks that also live on the same path, and those chunks may import other chunks and then those chunks are imported. You can see how if you have a deeply nested set of "chunks" this can quickly bloat response times and create the "waterfall" effect we see here. The "waterfall" is called that because each request is dependent on the request prior to it. Imagine you had multiple redirects
happening on the same route.
redirect_to "/foo" -> redirect_to "/bar" -> redirect_to "baz"
It's the same effect. You don't know you need the next asset until you request the asset. Or in this case, the javascript file.
Mitigating The Waterfall
Enter <link rel="modulepreload">
https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types/modulepreload
A module preload link allows you to request an asset without waiting for a browser to hit the chain of redirects for an asset. While good, its very hard to get right.
Let's look at https://jspm.org/ which is the default CDN used with the new Rails importmaps.
I generated a "modulepreload" graph for Shoelace-beta.78 and loaded it up in my index.html.
You can see this 'waterfall' is a little less and the initial "chunks" are all requested in parallel reducing the "waterfall" effect! Modulepreloads are good and work to great effect here with Shoelace! However, they add a lot of overhead of whenever you upgrade your shoelace version you have to generate a new preload graph.
Let's also think beyond the scope of Shoelace, as we add more and more dependencies this module preload section can get quite large!
When you use a bundler, static code analysis is performed and your assets can all get preloaded into one single bundle and this code analysis is far deeper and much more complex than the simple file imports allowed by module preloading.
Unused import treeshaking
With traditional JavaScript modules imported from a CDN there is no treeshaking. What you request is what you get. With a bundler like Parcel / Rollup / Webpack / Vite, they can statically analyze whatever code you import and get rid of any code paths that don't get used. CDN's dont have this privilege since they have no introspection into your final code.
Heres a very basic example of how treeshaking would work from a dependency.
export function doTheRoar () { return "roar" }
export function doTheMeow () { return "meow" }
Then, in our application code we would do something like this:
import * as doIt from "./my-package"
console.log(doIt.doTheRoar())
In the above case, a bundler would get rid of the "doTheMeow" function and only ship "doTheRoar" in our final production bundle thus reducing the amount of JavaScript we ship. If we were using a CDN, doTheRoar
and doTheMeow
will always ship even if they don't get used.
Scope Hoisting
While we're on the topic of efficiency, we can talk about "scope hoisting". Scope hoisting is effectively as follows from the Parcel docs:
...concatenates modules into a single scope when possible, rather than wrapping each module in a separate function. This is called “scope hoisting”. This helps make minification more effective, and also improves runtime performance by making references between modules static rather than dynamic object lookups.
https://parceljs.org/features/scope-hoisting/
Scope hoisting helps minimize the amount of JavaScript we ship as well as improving our performance. That sounds pretty good to me!
Chunking, Code Splitting, and Code Duplication
Frontend bundlers allow us to "chunk" or "code split" our packages and ship smaller bundles and take advantage of HTTP/2! We can code split in a variety of ways that I won't talk about, but bottom line is you can do it. Code splitting allows us to make optimized chunks.
From the Parcel docs:
...allows you to split your application code into separate bundles which can be loaded on demand, resulting in smaller initial bundle sizes and faster load times
https://parceljs.org/features/code-splitting/
This means we can get per-page chunks and do fancy things like "lazy-load" non-critical dependencies. Yes, you can do this with regular module scripts, but it is a lot more manual work.
Chunking / splitting also lends itself to talk about dependency duplication. With CDNs, you can easily bloat the amount of JavaScript you import when you import similar, but slightly different versioned dependencies.
For example, Shoelace imports Lit and lets say you wanted to use Lit in your project. Because of how Shoelace imports Lit inside of CDN's to make it self-contained, you do not get access to Lit, so it means you have to import the library again on your own meaning its not a "shared" dependency.
https://unpkg.com/browse/@shoelace-style/shoelace@2.0.0-beta.78/dist/chunks/chunk.WWAD5WF4.js
With frontend bundlers, we could easily hook into lit and build Shoelace's source ourself allowing us to ship a smaller final bundle by not duplicating dependencies.
Cache sharing across sites and domains
Our final point is in regards to cross-origin resource sharing. The common trope used to be
"if you import jquery on site A from a CDN and import jquery on site B from the same CDN, you don't incur the cost and the browser uses the cached version"
This isn't true anymore.
https://twitter.com/seldo/status/1486122838801063938
What does this change mean for you? If your sites live on modern hosting that provides a CDN and supports HTTP/2, you should drop the third-parties and ship all resources yourself. Relying on a third party resources offers little value in 2020.
While the final point may be contentious, especially those hosting their own resources on non-HTTP/2 routers (Looking at you Heroku), it is important to note that shipping first party dependencies offers a lot more control and can lead to better performance since an additional HTTP handshake doesn't need to be made to a 3rd party.
That's all folks!
While HTTP/2 offers a ton of performance benefits especially in regards to asset loading. It does not mean that we can't still find benefits in bundling our applications. At the end of the day, as long as you've evaluated the options and does what works best for your project, that's what really matters.
Top comments (2)
Very interesting! I still prefer importmaps over something like esbuild but your arguments make sense.
All depends on what you're building!