Skip to content

Import Maps and Browser-Native Modules

intermediate12 min read

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.

Mental Model

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:

BehaviorClassic ScriptModule Script (type=module)
LoadingBlocks HTML parsing (unless async/defer)Deferred by default (like defer)
Execution orderIn document order (blocking)After HTML parsing, in dependency order
ScopeGlobal scope — var creates window propertiesModule scope — variables are private
Strict modeSloppy mode by defaultStrict mode always
Duplicate executionRuns every time the tag appearsExecutes once, even if imported multiple times
CORSNot required for same-originAlways requires CORS headers for cross-origin
this at top levelwindowundefined
Quiz
What happens if you include the same module script tag twice in your HTML?

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.

nomodule is rarely needed in 2024+

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>
Common Trap

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.

Quiz
What problem do import maps solve for browser-native ES modules?

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.

Bundlers generate modulepreload hints automatically

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)
Quiz
Why do production web apps still use bundlers instead of serving native ES modules directly?

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.

CDN dependencies in production

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.

Key Rules
  1. 1script type=module defers by default, runs in strict mode, and has module scope (not global)
  2. 2Import maps translate bare specifiers to URLs — declared in a script type=importmap before any module scripts
  3. 3Only one import map per page, and it must appear before any module script tags
  4. 4modulepreload fetches, parses, and compiles modules upfront — eliminating the discovery waterfall
  5. 5Native ESM is great for development; bundlers are still better for production optimization
  6. 6ESM CDNs (esm.sh, jsDelivr) let you use npm packages in the browser without a build step