Import Maps and Browser-Native Modules
Modules in the Browser — No Bundler Required
For years, the only way to use modules in the browser was to bundle everything with webpack or Rollup. The browser didn't understand import statements, so a build tool had to resolve all your imports and produce a single (or few) JavaScript file(s).
That changed. Every modern browser now supports ES modules natively. You can write import and export in your source files, serve them directly, and the browser handles the dependency resolution. Add import maps, and you can even use bare specifiers like import React from 'react' — no bundler, no node_modules lookup.
Is this the end of bundlers? Not quite. But understanding native browser modules is essential for knowing what bundlers do for you and when you might not need them.
Think of browser-native ES modules like a restaurant where you order ingredients a la carte. Each import statement is a separate HTTP request for a specific file. Import maps are the menu — they translate friendly dish names ("react") into the actual kitchen location ("/vendor/react@19.1.0/index.js"). Without the menu, you'd have to know the exact file path for every ingredient. A bundler, by contrast, is a meal prep service that packages everything into one container before you sit down.
script type="module" — The Entry Point
To use ES modules in the browser, you add type="module" to your script tag:
<!-- ES Module — parsed, imports resolved, then executed -->
<script type="module">
import { greet } from './utils.js';
greet('World');
</script>
<!-- Or load from a file -->
<script type="module" src="./app.js"></script>
Module scripts behave differently from classic scripts in several important ways:
| Behavior | Classic Script | Module Script (type=module) |
|---|---|---|
| Loading | Blocks HTML parsing (unless async/defer) | Deferred by default (like defer) |
| Execution order | In document order (blocking) | After HTML parsing, in dependency order |
| Scope | Global scope — var creates window properties | Module scope — variables are private |
| Strict mode | Sloppy mode by default | Strict mode always |
| Duplicate execution | Runs every time the tag appears | Executes once, even if imported multiple times |
| CORS | Not required for same-origin | Always requires CORS headers for cross-origin |
| this at top level | window | undefined |
The nomodule Fallback
For backward compatibility, you can serve a bundled fallback to browsers that don't support modules:
<!-- Modern browsers load this -->
<script type="module" src="./app.js"></script>
<!-- Browsers without module support load this instead -->
<script nomodule src="./app-legacy.js"></script>
Modern browsers that understand type="module" will ignore nomodule scripts. Older browsers that don't understand type="module" skip it (unknown type) and run the nomodule script. It's a clean progressive enhancement pattern.
Every modern browser supports ES modules — Chrome 61+, Firefox 60+, Safari 11+, Edge 16+. That's well over 95% of global browser traffic. The nomodule pattern is mainly useful for large enterprise apps that still need IE11 support (which is increasingly rare).
Import Maps — The Missing Piece
There's a catch with browser-native ESM. In Node.js, you can write:
import React from 'react'; // "bare specifier"
Node.js knows to look in node_modules/react/. But the browser has no node_modules concept. Without extra help, every import must be a relative or absolute URL:
// This works in browsers — explicit path
import { render } from '/vendor/react-dom/client.js';
// This does NOT work in browsers — bare specifier
import { render } from 'react-dom/client';
// TypeError: Failed to resolve module specifier "react-dom/client"
Import maps solve this. They're a JSON mapping from bare specifiers to actual URLs, declared in a <script type="importmap"> tag:
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.1.0",
"react-dom/client": "https://esm.sh/react-dom@19.1.0/client",
"lodash-es": "/vendor/lodash-es@4.17.21/lodash.js",
"three": "/vendor/three@0.170.0/build/three.module.js"
}
}
</script>
<script type="module">
// Now bare specifiers work!
import React from 'react';
import { createRoot } from 'react-dom/client';
import { chunk } from 'lodash-es';
</script>
The importmap script must appear before any type="module" scripts in the HTML. The browser reads the import map during initialization. If you place it after a module script, the module will fail to resolve its specifiers because the map wasn't loaded yet. Also, there can only be one import map per page — multiple importmap scripts are not merged.
Scoped imports — different versions for different modules
Import maps support scopes, which let you provide different mappings for different parts of your app:
<script type="importmap">
{
"imports": {
"lodash": "/vendor/lodash@4.17.21/lodash.js"
},
"scopes": {
"/legacy-module/": {
"lodash": "/vendor/lodash@3.10.0/lodash.js"
}
}
}
</script>
Modules loaded from /legacy-module/ will get lodash 3.x, while everything else gets lodash 4.x. This is like node_modules nesting — different parts of the dependency tree can use different versions.
modulepreload — The Performance Hint
When the browser encounters a module import, it has to fetch the file, parse it, discover its imports, fetch those, and so on — a waterfall of sequential requests. modulepreload lets you tell the browser about these dependencies upfront:
<!-- Preload the module graph -->
<link rel="modulepreload" href="./app.js">
<link rel="modulepreload" href="./utils.js">
<link rel="modulepreload" href="./math.js">
<!-- By the time this runs, all modules are already fetched and parsed -->
<script type="module" src="./app.js"></script>
Unlike regular preload, modulepreload doesn't just fetch — it also parses and compiles the module, putting it into the module map ready for instant use. This eliminates the waterfall problem for known dependencies.
When you use Vite or a modern bundler in library mode, they add modulepreload link tags to the generated HTML for critical modules. You rarely need to add them manually — but understanding why they exist helps you debug loading performance.
When to Use Native Modules vs Bundlers
Native browser ES modules are great for:
- Development — Vite uses native ESM in dev mode for instant HMR
- Small projects — a handful of modules with no npm dependencies
- Prototyping — quick demos, CodePen/JSFiddle style experiments
- Server-side rendering — Node.js can load ESM natively
- Library demos — showcase a package without a build step
Bundlers are still better for:
- Production — bundling reduces HTTP requests and enables aggressive optimization
- Tree shaking — dead code elimination requires static analysis at build time
- Code splitting — intelligent chunking based on route boundaries
- Asset handling — CSS modules, images, SVGs, fonts as imports
- npm dependencies — hundreds of packages with complex dependency trees
// The key trade-off:
// Native ESM: 100 small files = 100 HTTP requests (even with HTTP/2, overhead adds up)
// Bundled: 100 small files = 3-5 optimized chunks (better compression, fewer round trips)
CDN-Based ESM — esm.sh, Skypack, jsDelivr
You don't need a local node_modules to use npm packages in the browser. ESM-focused CDNs convert npm packages to browser-compatible ES modules on the fly:
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.1.0",
"react-dom/client": "https://esm.sh/react-dom@19.1.0/client",
"three": "https://esm.sh/three@0.170.0"
}
}
</script>
<script type="module">
import React from 'react';
import { createRoot } from 'react-dom/client';
// Works directly in the browser — no build step
</script>
esm.sh — converts npm packages to ESM, supports TypeScript types, handles CJS-to-ESM conversion. The most popular choice.
jsDelivr — a general-purpose CDN that can serve npm packages as ESM with the /+esm suffix.
unpkg — serves files directly from npm packages. Not ESM-optimized but widely used.
Using CDN-hosted modules in production introduces a third-party dependency on your critical path. If the CDN goes down, your app breaks. For production apps, vendor your dependencies locally or use a bundler. CDN-based ESM is great for prototypes, demos, and development.
- 1script type=module defers by default, runs in strict mode, and has module scope (not global)
- 2Import maps translate bare specifiers to URLs — declared in a script type=importmap before any module scripts
- 3Only one import map per page, and it must appear before any module script tags
- 4modulepreload fetches, parses, and compiles modules upfront — eliminating the discovery waterfall
- 5Native ESM is great for development; bundlers are still better for production optimization
- 6ESM CDNs (esm.sh, jsDelivr) let you use npm packages in the browser without a build step