WSS
Web Specification Studio Home
On this page
BlogArchitecturePerformanceJavaScriptPublished

Buildless Architecture: Import Maps and modulepreload in Production

How to deploy production-ready JavaScript without a bundler using native ES modules, import maps, and modulepreload.

Summary: Import maps let a browser resolve bare module specifiers (react, lodash-es) the way Node or a bundler would, without a build step. modulepreload reduces the request waterfall that naive native-module loading otherwise creates, though it does not guarantee full dependency-graph preloading on its own. Together they make it realistic to ship production JavaScript directly as ES modules for a meaningful class of applications. They do not replace a bundler for everyone, and this article is specific about where that line sits.

Note: Browser behavior described here follows the published standards and documented engine behavior. Internal module-loader implementation details differ between engines and should not be treated as portable application guarantees beyond what’s stated in the spec.

Bundling became the default reflex in frontend development around 2015, for reasons that were correct at the time. Browsers didn’t support ES modules natively, HTTP/1.1 made many small requests expensive, and npm packages needed bare specifiers resolved to real paths. Two of those three reasons no longer hold universally.

Native ES modules now have broad support across current browsers, and import maps solve bare specifier resolution directly in the browser. This article is not an argument that bundlers are obsolete. It explains what import maps and modulepreload actually do, how they behave in production, and which applications can reasonably skip the bundling step as a result.

At a glance

QuestionAnswer
Need a bundler?No, for a moderate module graph on current browsers
Need a build step at all?Not necessarily, though TypeScript/JSX still need compiling
Resolve bare imports?Import maps
Preload dependencies?modulepreload, declared explicitly for reliability
Tree shaking?No
Code splitting?Native modules plus dynamic import()
Legacy browsers?Limited without a polyfill
Do workers see the page’s import map?No
Third-party integrity checking?Yes, via the import map integrity key
Does an import map make any npm package browser-ready?No, it only resolves specifiers, see below

What buildless means here

Buildless does not mean no tooling. Most real applications still author in TypeScript or JSX, still run a type checker, and still need those files compiled to plain JavaScript before a browser can run them.

What buildless means specifically is no bundling step: no concatenation of modules into chunks, no dependency graph flattening, and no bespoke module resolution algorithm reimplemented in a bundler.

Each source file stays a separate file in production, served as-is (or individually compiled, in the TypeScript case) and loaded by the browser’s native module resolver. The browser does the graph walking that a bundler would otherwise do at build time.

The problem import maps solve

Native import statements in the browser only resolve two kinds of specifiers without help: relative paths (./utils.js) and absolute URLs (https://example.com/lib.js). A bare specifier like import { useState } from 'react' has no meaning to a browser on its own, since there’s no node_modules resolution algorithm built into the platform.

An import map, declared with <script type="importmap">, tells the browser how to resolve those bare specifiers before any module script runs.

<script type="importmap">
{
  "imports": {
    "react": "https://esm.sh/[email protected]",
    "react-dom/client": "https://esm.sh/[email protected]/client",
    "lodash-es": "https://esm.sh/[email protected]"
  }
}
</script>

<script type="module">
  import { useState } from 'react';
  import { createRoot } from 'react-dom/client';
  // 'react' and 'react-dom/client' now resolve exactly like they
  // would after a bundler's resolution step, with no bundler involved.
</script>

This CDN URL is here purely to illustrate the mechanism, not as a production recommendation. The production patterns section below covers self-hosted alternatives and why they matter.

Every import statement in the document’s own module graph is resolved through the same map, including imports made by dependencies loaded from a different origin. This does not extend to modules loaded into workers or worklets, which is its own important caveat, covered separately below.

Browser support

Import maps are Baseline Widely Available and supported in current Chrome, Edge, Firefox, and Safari. The table below gives the versions each engine shipped support in, which matters if your audience includes older releases.

FeatureChromeEdgeFirefoxSafari
<script type="importmap">89+89+108+16.4+
integrity key in import maps127+127+138+18+

Can every npm package work without a bundler?

No, and this is worth being precise about. An import map solves specifier resolution: it maps the string react to a URL. It does not transform, convert, or validate the package that URL points to.

A given npm package may assume Node built-ins, ship only as CommonJS, rely on package.json conditional exports that a browser doesn’t evaluate the same way a bundler’s resolver would, reference filesystem APIs, or bundle its own CSS and asset imports that only a build tool understands. None of that is fixed by an import map.

What makes a package usable without a bundler is the package itself, or a CDN, publishing a browser-targeted ESM build with those concerns already resolved. This is exactly what CDNs like esm.sh do: they read a package’s package.json, resolve its dependency tree, and re-emit a browser-compatible ESM entry point.

A package that already ships a "browser" or ESM-specific exports condition tends to work cleanly this way; a Node-only utility library typically does not, with or without an import map pointed at it.

Import maps do not follow workers

An import map is scoped to the document. It does not currently apply to modules loaded into a dedicated worker, a shared worker, a service worker, or a worklet.

A bare specifier that resolves in the document does not automatically resolve inside a worker. Worker code therefore needs its own resolution strategy, such as relative or absolute URLs, or a build step specific to that worker’s bundle.

Service workers and buildless applications

The worker limitation above has a direct consequence for offline strategies. A service worker that precaches your application’s modules has to enumerate them itself, since it cannot rely on the page’s import map to tell it what to fetch.

This pairs naturally with the per-file caching model: a service worker precaching individually hashed module files can update its cache one file at a time, mirroring how the browser’s HTTP cache already treats them. It just has to do that bookkeeping itself rather than inheriting it from the document.

External import maps are not natively supported

The spec currently only allows an import map to be declared inline, as a literal JSON blob inside the <script> tag. A src attribute pointing at an external .json file, which would let a map be cached and shared across pages the way a stylesheet is, is not part of the spec and is not supported in any browser as of this writing.

The practical workaround uses an external classic script, not a module script, that runs and creates an inline <script type="importmap"> element via the DOM before any module begins loading.

<script src="/inject-import-map.js"></script>
<!-- inject-import-map.js is a plain classic script, not type="module",
     not async, not defer, so it runs and inserts the map before any
     module script on the page starts resolving specifiers. -->

This works back to Chrome 89, Safari 16.4, and Firefox 108, the same versions that support import maps at all. It is a workaround rather than a platform feature, worth watching the spec discussion on rather than assuming it will remain necessary forever.

Scopes: resolving version conflicts

Two dependencies sometimes need two different versions of the same package, the diamond dependency problem familiar from any package manager. Import maps handle this with scopes, which override the top-level mapping for imports triggered from a specific path prefix.

{
  "imports": {
    "chart-lib": "/vendor/[email protected]"
  },
  "scopes": {
    "/vendor/legacy-widget/": {
      "chart-lib": "/vendor/[email protected]"
    }
  }
}

Any module under /vendor/legacy-widget/ that imports chart-lib gets version 2.4.0. Everything else gets version 3.0.0. Both versions coexist on the same page without either dependency knowing the other exists.

Integrity: closing a real supply chain gap

Subresource Integrity has covered top-level <script> tags for years, but had no coverage for modules pulled in transitively through import statements, including dynamic import().

Chrome 127 shipped an integrity key for import maps; Safari 18 and Firefox 138 followed. The integrity object associates resolved module URLs with SRI metadata. When a static or dynamic import resolves to a URL covered by the map, the browser applies that integrity metadata to the fetch.

{
  "imports": {
    "react": "https://esm.sh/[email protected]"
  },
  "integrity": {
    "https://esm.sh/[email protected]": "sha384-Oq..."
  }
}

For any third-party CDN dependency, pair URL pinning with an integrity hash where the browser support you need allows it. This provides content verification similar to part of what a lockfile plus an SRI workflow gives you. It confirms that what you got matches what you expected, not why that URL was chosen or what its own transitive dependencies are.

The problem modulepreload solves

Without help, a deep module graph loads as a waterfall. The browser fetches the entry module, parses it far enough to discover its imports, then fetches those, then parses each of those to discover their imports, and so on. Each layer of the graph adds a full round trip before the next layer’s fetches even start.

<link rel="modulepreload"> tells the browser to fetch, parse, and compile a specific module ahead of execution, storing the result in the document’s module map. A browser may also choose to automatically fetch that module’s dependencies, but this is documented as a browser-specific optimization, not a guarantee.

The only cross-browser way to ensure a module’s dependencies are preloaded is to declare modulepreload links for them individually.

<link rel="modulepreload" href="/js/app.js">
<link rel="modulepreload" href="/js/router.js">
<link rel="modulepreload" href="/js/utils.js">
<script type="module" src="/js/app.js"></script>

The browser only reliably avoids the discovery waterfall for modules you’ve explicitly declared. Generating that list from a manifest or a graph crawl, rather than maintaining it by hand, is what makes this practical at scale.

modulepreload versus preload

The two are related but not interchangeable, and mixing them up produces subtle bugs.

preloadmodulepreload
Fetches the resourceYesYes
Parses itNoYes
Compiles itNoYes
Populates the module mapNoYes
Module-aware fetch behaviorMust be configured manually with asBuilt into the mechanism by default
Best suited forGeneric resources (fonts, images)JavaScript modules specifically

For anything that’s actually a JavaScript module, prefer modulepreload over preload as="script" unless you have a specific reason to treat it as a generic script resource instead.

Preload priority is not execution priority

modulepreload initiates an early fetch; it does not guarantee the browser treats that fetch as more urgent than everything else on the page. The browser’s own scheduler still weighs it against other in-flight requests, and fetchpriority can be used alongside it to signal relative importance.

Preloading is a hint the browser is free to deprioritize under load, not a promise of immediate delivery.

Don’t preload the whole graph

Preloading is a priority signal, not a free action. Resources given priority this way compete with other work for bandwidth and the main thread, so declaring modulepreload for everything defeats its own purpose.

Declaring modulepreload for a module a route rarely visits also defeats the point of splitting it out as a separate, lazily loaded chunk in the first place.

<link rel="modulepreload" href="/js/editor.js">

If editor.js is only opened by a small fraction of users, this line undoes the benefit that import('./editor.js') exists to provide. Reserve modulepreload for what the current page actually needs soon, and let dynamic import() handle the rest.

Browser support

modulepreload is Baseline Widely Available across current major browsers, having reached that status in March 2026.

FeatureChromeEdgeFirefoxSafari
rel="modulepreload"66+79+115+17+

Dynamic import for code you don’t need immediately

Not everything belongs in the initial modulepreload set. A code editor panel, a settings modal, or an admin-only view are natural candidates for loading on demand rather than up front.

button.addEventListener('click', async () => {
  const { openEditor } = await import('./editor.js');
  openEditor();
});

modulepreload and dynamic import() are complementary tools, not competing ones. Use modulepreload for what the current view needs and is likely to execute soon, and leave everything else as a plain dynamic import() that fetches only when the user actually triggers it.

Caching and deployment economics

Request count is not the only production trade-off between bundling and native modules. Deployment behavior differs just as much.

A bundle is typically one file, or a small number of chunks, built with a content hash in the filename. Change one line anywhere in the source tree, and the hash of the chunk containing that line changes, invalidating every client’s cached copy of that chunk.

With native modules served individually, each file can carry its own content hash and its own long-lived, immutable cache header. Changing ui.js only invalidates the cached copy of ui.js; router.js, auth.js, and every third-party dependency stay cached exactly as they were. For an application that ships frequently, this means most returning visitors redownload only the files that actually changed.

This does not make native modules strictly better for caching in every case. A single well-tuned bundle with stable vendor chunk splitting can achieve similar reuse for the specific shape of a given application. The point is that the two approaches optimize cache invalidation differently.

Compression and transfer overhead

HTTP/2 and HTTP/3 make many concurrent requests far cheaper than they were under HTTP/1.1, but neither makes additional files free. Each response still carries its own headers, and general-purpose compression such as gzip or Brotli works best over a larger, more repetitive stream of bytes.

A single large bundle often compresses somewhat better than the same code split across many small files, each compressed independently. It’s rarely large enough by itself to be decisive, but it belongs in the same trade-off calculation as request count.

What you give up

Be specific about the trade-offs; a buildless setup is not a strictly better version of a bundled one.

No build-time tree shaking: A bundler that sees the whole dependency graph at build time can drop unused exports and unreachable code. The browser does not perform this kind of whole-program dead-code elimination, so unused exports and unreachable branches within a dependency remain in what gets downloaded.

No automatic production optimization: Most CDN-hosted packages do serve pre-minified builds, which narrows this gap in practice. Your own first-party code, however, ships exactly as written unless you add minification, compression tuning, and asset fingerprinting for it specifically, which reintroduces a small build step.

Per-file overhead is real, even under HTTP/2 and HTTP/3: Multiplexing removes the connection-count bottleneck, but each module file still carries its own headers, its own compression context, and its own parse and compile pass in the JavaScript engine. A large number of very small first-party files will generally still load faster consolidated into fewer, larger files.

No lockfile, natively: An import map pins specifiers to URLs, but the platform itself records nothing about where those URLs came from or what version range produced them. Generator tools such as the JSPM CLI close part of this gap by resolving a full dependency graph and emitting an import map with integrity hashes attached.

Waterfalls return if you forget modulepreload: As covered above, the browser only reliably preloads what you declare. A deep graph with no preload hints degrades back to the layer-by-layer discovery pattern this feature exists to avoid.

Content Security Policy

A CSP already constrains where scripts and modules can load from, and it interacts directly with everything above. script-src controls which origins module scripts, dynamic imports, and worker scripts may be fetched from, so a CDN entry in your import map has to be reflected in that directive.

connect-src matters too for any module that itself performs fetches, since import resolution and runtime network calls are governed separately. None of this is unique to a buildless setup; it’s the same policy work a bundled application already needs.

How this looks in production

Three patterns cover most real deployments today.

CDN-hosted dependencies: CDNs such as esm.sh, jsDelivr’s ESM endpoint, and unpkg can expose npm packages in a form usable by native module loading. This is the fastest way to get started and works well for prototypes, but it puts a third-party origin in your page’s trust and availability path.

Self-hosted vendor mirror: Fetch the same CDN-resolved modules at build time, or via a scheduled job, and serve them from your own origin, with an import map pointing at your own paths. This removes the third-party availability dependency while keeping the buildless serving model for your own code.

Generated import maps: Tools such as the JSPM CLI take a package.json and produce a complete import map, including scopes for version conflicts and integrity hashes. The output is a static artifact the browser consumes directly, rather than an internal build-time data structure.

A minimal production layout

/public
  index.html        (import map + modulepreload links)
  /js
    main.js
    router.js
    state.js
    editor.js        (loaded via dynamic import(), not preloaded)
  /vendor
    react.js
    react-dom.js

index.html declares the import map and modulepreload for main.js, router.js, and state.js, which the application needs immediately. editor.js stays out of the preload list and is fetched only when a user opens the editor.

When a bundler is still the right choice

Stated directly:

  • Large, deeply split first-party codebases. The per-file overhead adds up as module count grows, and a bundler’s ability to merge related modules into fewer, larger chunks tends to win on raw load performance.
  • Support for browsers below the 2023 baseline. If your audience includes a meaningful share of pre-2023 Safari or Firefox, native module loading isn’t an option without a polyfill.
  • Splitting along boundaries a human wouldn’t draw by hand, such as shared-chunk extraction across routes. modulepreload and dynamic import() express splits you define explicitly; they don’t restructure a graph the way a bundler’s optimizer can.
  • Dependencies that aren’t published as browser-ready ESM. An import map can’t fix a Node-only package on its own, and a bundler’s resolution and transform pipeline remains the simpler path for those dependencies.
  • A working bundler pipeline with no active problem. Migrating away from a functioning setup because buildless sounds simpler is not a good reason on its own.

Decision framework

Ask the following before committing either way:

  1. How large and deeply split is your first-party module graph? A modest number of well-sized modules tends to favor buildless; a very large graph of many small files tends to favor bundling.
  2. Does your audience include browsers older than the 2023 baseline (Safari 16.4, Firefox 108, Chrome 89)? If yes, a bundler with a legacy build target is the simpler path.
  3. Do your dependencies already ship as browser-ready ESM, or would you be fighting Node-only packages? The latter favors bundling regardless of graph size.
  4. Is your team already invested in a working bundler pipeline with no active pain point? Don’t migrate a system that isn’t broken.

Where the graph is a moderate size, targeting current browsers, with dependencies available as native ESM from a CDN or a self-hosted mirror, import maps plus modulepreload are now a realistic production choice.

Frequently Asked Questions

Does an import map work for CSS or WASM imports?

Yes, provided you are using the modern ES Module integration features for those asset types (such as Import Attributes with type: "css" or type: "webassembly"). The import map simply resolves the bare specifier string; the browser’s module loader handles the actual asset parsing based on the response MIME type and import attributes.

Can I use import maps with Service Workers?

No. Import maps are strictly scoped to the Document that declared them. Service Workers, Dedicated Workers, and Shared Workers operate in their own global scope and do not inherit the page’s import map. You must use fully qualified URLs or relative paths within worker scripts.

What happens if I declare multiple import maps on one page?

As of recent specification updates (and implementations in Chrome 130+), multiple import maps are merged sequentially. However, in older browsers, declaring a second import map on a page would throw an error and be ignored. For maximum compatibility, concatenate your mappings into a single <script type="importmap"> tag.

Can I dynamically inject an import map using JavaScript?

Yes, but only if you inject the <script type="importmap"> element into the DOM before any ES modules are imported or executed. Once the browser begins resolving module specifiers, the import map is locked, and any subsequent attempts to inject or modify an import map will be ignored and throw a console error.

Do import maps support fallback URLs?

No. Unlike some bundler features or CDN loaders that can fallback to a secondary URL if the primary fails, native import maps map exactly one specifier to one URL. To handle network failures, you must implement a Service Worker to intercept the failing fetch request and return an alternative response.

Does modulepreload execute the script immediately?

No. <link rel="modulepreload"> tells the browser to fetch the script, parse it, and compile it into bytecode in the background, but it explicitly does not execute it. The code is only evaluated when a standard <script type="module"> tag or dynamic import() actually requests it.

How does modulepreload affect Core Web Vitals?

When used correctly, modulepreload dramatically improves Largest Contentful Paint (LCP) by parallelizing the download of critical dependencies rather than waiting for the waterfall to discover them. However, preloading too many non-critical modules can saturate bandwidth, delaying the download of LCP images or blocking the main thread, which worsens both LCP and Interaction to Next Paint (INP).

See also

From the team at

We build digital products and explore the modern web standards behind them.

Related posts