An import map tells the browser where a bare specifier points. Write import { circle } from "shapes" in a plain <script type="module">, give the page a map, and it resolves with no build step at all. The import maps vs bundler decision turns on what else you need, because a map does no tree-shaking, no minification and no chunking.
Import maps have been Baseline widely available since March 2023, so this is not a bet on something unshipped. Two later additions decide whether the approach survives a real site: multiple maps per document, and integrity hashes.
What does an import map actually do?
It is a JSON object in an inline script with three optional keys: imports, scopes and integrity. The imports key maps specifier text to a URL. A key ending in / acts as a prefix, and MDN is strict about the pairing: “If a key ends with /, then the corresponding value must also end with /.” Where several keys could match, the most specific one wins.
<script type="importmap">
{
"imports": {
"shapes": "/vendor/shapes/index.js",
"shapes/": "/vendor/shapes/",
"@app/": "/js/app/"
},
"scopes": {
"/js/legacy/": {
"shapes": "/vendor/shapes-v1/index.js"
}
}
}
</script>
<script type="module" src="/js/app/main.js"></script>
That scopes block is the part people miss. It makes the mapping depend on which file is doing the importing, so anything under /js/legacy/ gets the old copy of shapes while the rest of the page gets the current one. If more than one scope matches, the longest scope key wins. Two versions of a dependency on one page, no bundler, no aliasing config.
Three limits are worth knowing before you plan around it. The map must be inline, because MDN says the src, async, defer, crossorigin, integrity and referrerpolicy attributes “must not be specified” on the element, so you cannot serve it as a separately cacheable file. It does not apply to the src of a <script> element, nor to modules loaded into workers or worklets. And an unresolvable specifier is not a warning: JavaScript “will throw a TypeError if it attempts to import a module specifier that can’t be resolved to a module location”.
Import maps vs a bundler: which does your project need?
Use the map alone when the dependency list is short, hand-picked, and already shipped as ESM. Internal tools, admin panels, a marketing site with a couple of web components, Rails or Django apps where the JavaScript is glue rather than the product. You get an editable page, a readable network tab, and no build step to keep alive.
Reach for the bundler when npm is doing real work for you. MDN puts the case plainly: native modules do not obsolete bundlers, “bundlers still do a good job at partitioning code into reasonably sized chunks, and are able to do other optimizations like minification, dead code elimination, and tree-shaking”.
The request waterfall usually bites before any of that does. The browser cannot discover shapes/circle.js until it has fetched and parsed the module that imports it, so a deep dependency graph turns into a chain of round trips. A bundler flattens that chain at build time. Without one you flatten it by hand, with rel="modulepreload", Baseline since September 2023:
<script type="importmap">
{
"imports": {
"shapes/": "/vendor/shapes/"
}
}
</script>
<link rel="modulepreload" href="/js/app/main.js" />
<link rel="modulepreload" href="/vendor/shapes/circle.js" />
<link rel="modulepreload" href="/vendor/shapes/square.js" />
<script type="module" src="/js/app/main.js"></script>
Note the repetition.
A browser may fetch a module’s dependencies for you, but MDN calls that “a browser-specific optimization” and says “the only approach to ensure that all browsers will try to preload a module’s dependencies is to individually specify them”. So you are maintaining a hand-written list of every module on the critical path, and nothing tells you when it goes stale. Once that list runs past a dozen lines, you have rebuilt the worst part of a bundler without the tooling that keeps it correct.
Can a page have more than one import map?
Chrome and Edge from 133, Safari from 18.4. Firefox has not shipped it as of September 2026, so this one is not Baseline.
The original rule was unforgiving, which is why the change is worth knowing about. The Chrome 133 notes describe it exactly: “Import maps currently have to load before any ES module and there can only be a single import map per document.” One map, loaded first, covering everything. On a large page that map “become[s] a large blocking resource, as the entire map for all possible modules needs to load first”, and any module that sneaks in ahead of it breaks resolution.
Shopify pushed the fix through the spec, Chromium and WebKit, and their write-up on resilient import maps is the clearest account of the merge rules. Browsers keep one global map and merge each new one into it. Specifiers that were already resolved get dropped, and where a specifier is already mapped, the previous mapping prevails. Later maps extend, they do not override.
So if third parties inject script into your pages, themes, apps, extensions, a tag manager, you now have a way to let them bring their own dependencies. If you support Firefox, and you do, treat one map as the contract and multiple maps as an enhancement. Shopify falls back to the es-module-shims polyfill for browsers without native support, which is how the remaining few percent of users get covered.
How do you stop a CDN swapping your dependency?
Use the integrity key. It maps a module URL to a Subresource Integrity hash, and the browser refuses to run the module if the bytes do not match.
{
"imports": {
"shapes/": "/vendor/shapes/"
},
"integrity": {
"/vendor/shapes/circle.js": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
}
}
SRI already covered top-level scripts. What Shopify’s work on module script integrity added was coverage for statically and dynamically imported modules, which is where a CDN-hosted dependency graph is actually exposed. Support landed in Chromium 127, Safari 18 and Firefox 138. If you are pulling modules from a public CDN without pinning hashes, an account takeover at the other end is a script injection on your site. The same reasoning applies to the script tags you already have, which we covered in our post on CSP nonces and hashes.
What we would do
Ship an import map when the JavaScript is a handful of hand-picked ESM dependencies and the build step exists only to rewrite specifiers. Pin every CDN URL with an integrity entry, add modulepreload for the critical path, and keep it to one map so Firefox behaves.
Keep the bundler when npm is load-bearing: a dependency graph you did not curate, CommonJS anywhere in it, code splitting per route, or a bundle size someone is measuring. The waterfall and the missing tree-shaking cost more than the build config saves, and we would rather tune a build than delete it, which is roughly where our Vite 8 Rolldown migration notes land.
Whoooop builds and maintains front ends where the loading strategy is part of the brief rather than an afterthought. If a site is slow because of how its JavaScript is discovered and fetched rather than how much of it there is, that is the kind of problem our site speed work exists to fix.