How to Use Import Maps for Unbundled Production
This guide explores a no-bundler architecture within Modern Module Formats: ESM vs CommonJS, part of JavaScript Bundle Optimization & Code Splitting. Browsers support ES modules natively, and import maps — a JSON block in the HTML that maps bare specifiers like "lit" to URLs — let source code use the same import { html } from 'lit' it would use with a bundler. Rails 7+ ships this approach by default, and many small and medium sites run in production without any JavaScript bundler.
The appeal is simplicity: no build step for JavaScript, each file cached independently, and deploys that change only the files you edited. The performance risk is the module waterfall: the browser discovers imports only as it parses each module, so a deep import graph loads level by level. With HTTP/2 or HTTP/3, modulepreload hints and a modest graph, unbundled sites can be fast; with a large graph and no preloading, they are slow.
Rapid Diagnosis
- Count modules on the critical path. Network panel, filter JS. Dozens of small module requests at load suggest a graph that needs preloading or partial bundling.
- Look for staircase patterns in the waterfall: each level starting only after the previous finishes.
- Check protocol. Unbundled loading over HTTP/1.1 is impractical; confirm HTTP/2 or HTTP/3.
- Check compression and caching per file. Small files compress less efficiently; long-lived caching of each file is the main benefit.
Root Cause Analysis
1. Import discovery waterfalls. Each module's imports are only known after it is fetched and parsed.
2. Per-request overhead. Hundreds of small files add request headers, scheduling and less effective compression.
3. No tree shaking. Without a bundler, unused exports in imported files still download; only file-level granularity applies.
4. Third-party packages not designed for direct use. Many npm packages ship CommonJS or deep internal import graphs unsuitable for the browser without bundling.
Step-by-Step Resolution
1. Define the import map and pin versions
<script type="importmap">
{
"imports": {
"lit": "/vendor/lit@3.2.1/index.js",
"lit/": "/vendor/lit@3.2.1/",
"@app/": "/js/"
}
}
</script>
<script type="module" src="/js/app-5c1e.js"></script>
<!-- trade-off: the import map must appear before any module script, and only
one map is allowed per document in older browsers. Generate it at build
or deploy time from a lock file so versions cannot drift. -->
Expected outcome: source uses bare specifiers; the browser resolves them through the map.
2. Preload the critical module graph
Emit <link rel="modulepreload"> for every module reached at startup, so the browser fetches them in parallel instead of discovering them level by level.
<link rel="modulepreload" href="/js/app-5c1e.js">
<link rel="modulepreload" href="/js/router-0a7d.js">
<link rel="modulepreload" href="/vendor/lit@3.2.1/index.js">
<link rel="modulepreload" href="/vendor/lit@3.2.1/lit-element.js">
<!-- trade-off: hand-maintained preload lists rot. Generate them by walking the
import graph at deploy time (tools like importmap-rails or JSPM do this). -->
Expected outcome: the waterfall collapses into one parallel batch.
3. Fingerprint files and cache immutably
Include content hashes in file names (or in the import map's URLs) and serve them with Cache-Control: public, max-age=31536000, immutable. Deploys change only the hashes of edited files; everything else stays cached — a genuine advantage over bundles where one change rehashes a large chunk.
4. Bundle selectively where it pays
Pre-bundle heavy dependencies with deep graphs into a single ESM file each (with esbuild or a CDN's bundled ESM endpoint), and keep your own code unbundled. This hybrid keeps most of the simplicity while removing the worst waterfalls.
npx esbuild node_modules/some-chart-lib/index.js --bundle --format=esm --minify \
--outfile=public/vendor/some-chart-lib.js
# trade-off: pre-bundled vendor files lose cross-package deduplication; if two
# pre-bundled libraries share a dependency, it is included twice.
Verification
Record a throttled trace: all startup modules should be requested in a single parallel batch shortly after the HTML, with no staircase. Compare LCP and TBT with a bundled build of the same app if available. After a small deploy, confirm returning visitors re-download only the changed files (transfer sizes of zero for cached modules in the Network panel).
Worked Example: A Content Site with Progressive Enhancement
A documentation site used about 25 small modules for search, theme toggling and code-copy buttons, plus one web-component library. Initially unbundled without preloads, its startup JavaScript loaded in four sequential levels, finishing at 1.1s on a mobile profile. Adding generated modulepreload links collapsed it to one batch finishing at 330ms; pre-bundling the web-component library removed eleven tiny requests. The team kept the setup because deploys — mostly content and small script tweaks — now invalidated one or two files instead of a whole bundle, and the build pipeline lost its JavaScript step entirely.
Common Mistakes
- Shipping unbundled code without preloads. The waterfall is the defining performance risk of this approach.
- Pointing the import map at third-party CDNs without integrity. Use Subresource Integrity (the
integritymap in import maps where supported) or self-host. - Unminified source in production. No bundler does not mean no minifier; minify files individually.
- Ignoring old browsers. Import maps are widely supported now; if you must support older engines, a shim exists, but it adds startup cost.
Edge Cases
Dynamic imports and the map. import('lit/directives/repeat.js') resolves through the map like static imports, so lazy loading works — but lazy modules are not covered by startup modulepreload and may create their own small waterfalls when triggered.
Multiple import maps. Modern browsers allow multiple import maps, merged in order, which helps micro-frontends; older versions accepted only one. Test in your oldest supported browser.
Service workers. A service worker can precache the whole module graph for repeat visits, removing network cost entirely for returning users — a natural fit for an unbundled site with many small files.
CSS in modules. CSS module scripts (import sheet from './x.css' with { type: 'css' }) are an option in some engines; for broad support, keep CSS as normal stylesheets.
FAQ
Is HTTP/2 enough to make unbundled loading fast?
HTTP/2 removes the per-connection request limit and makes many small requests cheap, but it cannot fix discovery waterfalls — the browser still does not know about a module until its importer is parsed. Preloading is what fixes that.
Does an import map block rendering?
It is an inline script element that the parser processes quickly; it does not fetch anything itself. The cost is that module scripts cannot start resolving bare specifiers until the map is parsed, which is why it belongs early in the head.
Can I use npm packages directly?
Only those published as browser-ready ESM without Node-specific imports. Many are; many are not. Tools like JSPM or ESM CDNs can generate browser-ready versions and maps for you.
How does this compare with Vite in development?
Vite's dev server also serves unbundled ES modules, but it pre-bundles dependencies and transforms source on demand. Production with import maps is the same idea without the dev-server conveniences — closer to what the browser runs in development with Vite than a bundled production build is.
What about integrity for modules loaded through import maps?
Import maps support an integrity field mapping module URLs to Subresource Integrity hashes in browsers that implement it, so dynamically imported and transitively loaded modules can be verified. Where unsupported, self-hosting the modules on your own origin is the safer default.
Related
- Using modulepreload for ES module graphs — the hint that makes this approach viable.
- Shipping ESM-only packages — dependencies that work without a bundler.
- Measuring real-world HTTP/3 gains — the transport many-file delivery depends on.