If you’re working on Shopify themes or App Blocks, you can now use JavaScript modules and import maps to improve your caching performance without worrying about conflicts or loading order issues. We made sure that is the case by fixing the underlying web platform primitives and making them easier and safer to use for everyone!
As part of Shopify’s move to faster build-pipelines, we’ve started adopting JavaScript modules as a platform primitive. However, when we migrated the Online Store Editor to use them, we were quickly notified this change caused issues for themes using import maps. Our use of JavaScript modules meant that for such themes, all their modules failed to load!
After some research, this turned out to be a well-known issue with JavaScript modules and import maps – loading any module before the map causes the map to throw. Theme authors also faced the same loading failures when working with App Blocks that used import maps.
When thinking through possible solutions, we determined that the only way to provide theme and app developers with the freedom to use import maps was to solve this at the web platform level, as import maps were just too fragile to use reliably.
Because we believed import maps and JavaScript modules are important web platform primitives, we did just that!
What’s so great about import maps?
Import maps make it possible to refer to JavaScript modules using “bare specifiers”, which is a fancy name for… well, names. That means that with import maps, you can name your modules and then refer to them in import statements using those names, rather than by URLs.
That’s a neat ergonomic advantage, as you don’t have to worry about the hashed URL of the script when referencing it. Beyond ergonomics, this capability also delivers a significant performance benefit – it solves cache invalidation cascades. In short, import maps and bare specifiers enable you to update one of your modules without changing all of the modules that reference it (and the modules that reference those modules recursively).
Import maps achieve this by decoupling the referenced module name in the code from the URL with which that module is loaded. This enables developers to deliver modules with a URL that contains the content’s hash and immutably cache them. Any change to the content would also result in a change to the URL, so developers don’t need to worry about caching lifetimes. When content changes, developers can only change their relevant import map rule instead of actual references to that script, leaving the code of other modules intact.
At Shopify, we wanted to enable theme developers to use import maps for their performance benefits and apply them in Shopify’s own JavaScript-heavy apps without introducing fragility issues that made them hard to adopt.
What made import maps hard to adopt?
Adoption of import maps was hampered by two requirements:
- No module was allowed to load before the import map was loaded.
- Import maps were similar to highlanders - there could be only one.
These requirements stem from JavaScript’s module system requirement that module resolution would not change over time - a module that resolves to a certain URL needs to continue resolving to the same URL through the lifetime of the document.
If a module is loaded before the import map, the import map could change its resolution URL.
And multiple import maps could have conflicting rules, and could resolve different bare module identifiers to different URLs. Modules loaded in between those maps could change their resolution over time.
The original implementers of import maps decided to ban these scenarios, and throw errors if they happen. While that was a great way to get import maps out the door and enable developers to use them in certain situations, this also proved (in the years since import maps initially launched) as a hurdle to adoption.
No modules before maps
The issues that Shopify storefronts were hitting around import maps resulted from the loading of modules before the theme’s import maps. Essentially, we were unable to load our scripts as modules as some themes may want to use import maps and break everything.
Similarly, theme authors were running into issues when using modules because some App developers were using import maps which resulted in similar breakage. In some cases, web developers were even hitting issues with user extensions, as extensions that were injecting modules early on prevented import maps from working.
Overall, real-life web pages are not necessarily written by a single author, and in many cases it is practically impossible for developers to guarantee that all their modules are loaded after the import map.
There can be only one
An additional hurdle to adoption was that only a single import map could load on the page. So if theme developers and App developers were both using maps, that would result in a collision. Similarly, that means that Shopify could not use them in its own code, despite their performance improvements.
But worse, combining this restriction with the “no modules before maps” one meant that the import map itself had to be a single execution-blocking block at the top of the HTML, before any modules were loaded.
That meant that for our large long-running, JS-based apps (e.g. Shopify’s Admin), we couldn’t adopt import maps as an import map that covers all the modules the app may load would be huge and would create loading performance issues that potentially shadow the caching benefits it may provide. Not great!
Solving adoption issues
The problems described above weren’t novel, and were pointed out before.
But solving them never gained enough traction, so they lingered, hurting import map adoption.
Both of these limitations were a result of the fact that import maps had no reconciliation mechanisms between themselves or between past-loaded modules. What we needed was such a mechanism that would allow us to merge multiple import maps without changing the resolution of already loaded modules and existing rules.
Along with Jake Archibald, we read through past discussions, talked through them, and came up with a plan that spelled out what a reconciliation mechanism may look like. In short, import-map rules that impact the resolution of past-loaded modules will get dropped from the map. Similarly, a new import map will not be able to add rules that override ones defined by a past-loaded import map.
We ran this by Domenic Denicola and Hiroshige Hayashizaki, got their early thumbs up, and moved the discussion to the HTML spec, to hash out the details in a broader forum. Guy Bedford, the author of the ES-Module-Shim polyfill and the one who reported many of the original issues also chimed in.
Together, we crossed all the Ts and landed the spec change. In parallel, we implemented the feature in Chromium (with reviews from Domenic & Hiroshige), and eventually shipped it. After a positive position from the WebKit team, we also landed it there (Thanks to Anne van Kesteren and Yusuke Suzuki for their help and reviews!).
Nowadays, you can have multiple import maps with modules loading before them or between them. Hooray!! 🎉
What about older browsers?
From Shopify’s perspective, the issues we were seeing with import maps were not just missing performance improvements, but correctness issues. Getting the feature supported in the latest versions of Chrome, Edge and Safari was a great first step, but we couldn’t abandon users on older or other browsers.
That’s where Guy Bedford came in and whipped together es-modules-shim support for multiple import maps. With amazing work from Fran Dios, Shopify then started injecting the polyfill to storefronts whenever we detected that a page included an import map and was accessed by a browser lacking native support.
Later on, we ran into some issues in cases where theme or app developers already included that polyfill, which Fran and Guy managed to fix and push to the latest polyfill version.
Nowadays, the feature is shipping in Chrome (starting from version 133) and Safari (starting from 18.4). Hopefully, Firefox will follow suit.
Conclusion
As a developer, you can now use import maps and JavaScript modules without concerns about their inclusion order, or needing to include all the modules your document will ever need in the initial map. Our internal development teams are already using modules and import maps successfully. In fact, our recently released flagship theme Horizon is heavily relying on them.
For browsers without native import map support, you may need to conditionally load the es-modules-shim polyfill (version 2.4.0 or higher).
We’re super excited we were able to push forward this work and solve our issues while improving correctness and performance on the web for everyone!