For the last year, I’ve been running a single-page web app powered by ES modules in production.
When I launched the app in August 2019, the industry consensus was that using unbundled ES modules in production was a bad idea. Khan Academy tried unbundling their homepage JS and concluded that it slowed down their initial page load. That was five years ago and I still don’t know of anybody who seriously considers skipping the bundler and using ES modules in production.*
I kept looking for examples of people doing this but I struggled to find them, so I decided I’d give it a try and see how bad it really was. Here’s what I found:
Things that met expectations
Development experience. I expected the development experience to be great, and it was. There’s no setup instructions, no startup delays. No need to watch files, generate source maps, or wait for things to recompile. Just save the file and refresh. ✨
Deployment. Deployment was straightforward. All I needed to do was copy my code to the server as-is. My web host Netlify makes it easy to do this on git push (though, to be fair, Netlify can make even the most complex setups easy to deploy).
Dev/Prod parity. Every time I found a production bug I was able to reproduce it locally. Not a major goal, but very convenient.
Things that were worse than expected
Dependencies not supporting ES modules. I often found libraries I wanted to use, only to learn that they didn’t support ES modules. They usually supported CommonJS instead, which meant I couldn’t use them. At first, I was working around this by loading versions of the library that use browser globals (either via script tag, or side-effects import). This worked, but it didn’t feel ideal.
Eventually, I started using esinstall, which can import dependencies that don’t support ES modules and produce a one-time build that does. This worked so well that I’ve started using it on other projects.
Environment variables. Typically, I’d assign these at build-time but you can’t when there’s no build. Fortunately, Cory House has a great post describing all the options. I ended up using environment sniffing which feels a little weird, but ultimately isn’t a big deal for my app.
CSS organization. I went with traditional CSS using BEM conventions, which was fine. I still wanted to break up my files though, so I used a
main.css file with a bunch of
@imports. That felt better, but then I had a blocking request that delayed page rendering so I moved the
@imports into an inline style tag. I’m not sure if I like it, so I may keep iterating on this.
Things that were better than expected
Performance. Everything I heard said that ES module performance was going to be terrible, even with HTTP/2. So I braced myself and… it’s been great. I haven’t even done any optimizations beyond making sure that my initial HTML file had some good default markup. I suspect the performance is good because my app isn’t big enough to start hitting any bottlenecks yet (this research says you’ll be fine if you’re below a couple hundred modules, which seems to be confirmed anecdotally). This made me realize that that my intuition was off on what was “too big” of an app. You can go a long way before you’re in a place where you need to load 300-500 files, all at once. It feels unlikely that I’ll reach those limits on my app, at least.
Browser support. Since I wasn’t using Babel, I expected lots of cross-browser issues but they were rare.** It turns out that once you drop IE11 support, browser support of modern JS features is really good. Things like arrow functions, const/let, template strings, ES6 classes, and
fetch all have over 95% global support (and that’s including IE11). The only time I didn’t get to use a JS feature I really wanted was the optional chaining operator, and that feature will probably have 95% support in the next year or two. Evergreen browsers are a powerful thing.
I’ve been pleasantly surprised. If this were a typical, bundled, single-page-application, I would have needed to deal with one or two major tooling upgrades by now. Instead, I’ve been able to focus on features. Native web technologies FTW!
If my module count gets so big that performance starts to noticeably degrade, then I could always set up bundling as an optimization for production. Keeping it production-only preserves that great local-dev experience while avoiding unnecessary complexity. I expect it would be fairly straightforward to setup esbuild (or a similar tool) to do this, since an ESM-friendly codebase should be bundle-able without any changes.
I like this idea of postponing complexity. It feels very agile.
Maybe ES modules aren’t for every project but they’ve worked pretty well for mine. If there are fundamental flaws, I haven’t found them yet.
Honestly, it’s hard for me to imagine building a side-project any other way right now.
* I’ve just recently found a few other examples of ES Modules in production including runpkg.com (source) and Phillip Walton’s blog (source). If anyone knows of others, please let me know via email or the comments.
** More accurately, I have faced some cross-browser issues but they weren’t the kind that Babel could help me with. Babel’s polyfills don’t help when the browser implementation has bugs or your approach is fundamentally flawed. 🙃