DEV Community

Cover image for One version bump, 40 files to edit. Import maps fix that.
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

One version bump, 40 files to edit. Import maps fix that.

grep -rl "esm.sh/lodash-es@4.17.21" src/ came back with forty-one files. Forty-one places where the same CDN URL, pinned to the same version, was typed out by hand at the top of a module. The security advisory said bump to 4.17.22. A sed one-liner across the repo felt fine until it silently skipped the two files that imported a named subpath with a slightly different query string.

That's the moment you find out your "no build step" app has a build-step problem anyway — you just moved it into your find-and-replace history.

Why you can't just write import _ from "lodash"

Node resolves that specifier by walking node_modules. A browser has no node_modules to walk. Try it in a plain <script type="module"> and you get this:

Uncaught TypeError: Failed to resolve module specifier "lodash".
Relative references must start with either "/", "./", or "../".
Enter fullscreen mode Exit fullscreen mode

The ES module spec only lets a browser resolve three kinds of specifiers on its own: a full URL, or a path that starts with /, ./, or ../. "lodash" is none of those — it's a bare specifier, and bare specifiers only mean something if a bundler rewrites them at build time, or a package manager's resolution algorithm (the kind Node has, and browsers don't) fills in the blank.

So teams without a bundler reach for the next-easiest thing: paste the full CDN URL directly.

import debounce from "https://esm.sh/lodash-es@4.17.21/debounce";
import throttle from "https://esm.sh/lodash-es@4.17.21/throttle";
import cloneDeep from "https://esm.sh/lodash-es@4.17.21/cloneDeep";
Enter fullscreen mode Exit fullscreen mode

It works. It also means the version number is now a string literal duplicated in every file that imports anything from that package — which is exactly the forty-one-files problem above, just discovered slightly later.

The browser has had the real fix since 2023

An import map is a small JSON block that tells the browser: "when you see this bare specifier, resolve it to this URL." One declaration, and every import statement in the page — in every module, in every file — goes through it.

<script type="importmap">
{
  "imports": {
    "lodash-es/": "https://esm.sh/lodash-es@4.17.21/"
  }
}
</script>
<script type="module">
  import debounce from "lodash-es/debounce";
  import throttle from "lodash-es/throttle";
</script>
Enter fullscreen mode Exit fullscreen mode

Now every file in your app writes import debounce from "lodash-es/debounce" — a specifier that looks exactly like the one that would fail with no import map at all. The version lives in exactly one place. Bumping it is a one-line diff, not a repo-wide search.

Notice the trailing slash on both the key and the value — that's not decoration. A key ending in / is a prefix mapping: anything that starts with lodash-es/ gets that prefix swapped for the mapped URL, and the rest of the specifier is appended. The value must end in / too, or the browser refuses to register the mapping at all. Try "lodash-es": "https://esm.sh/lodash-es@4.17.21" — no trailing slash — and it's only a mapping for the exact specifier "lodash-es", not for "lodash-es/debounce".

When two parts of your app need two versions

Say your main app is on lodash-es@4.17.22, but a legacy /admin section is pinned to 4.17.19 until someone gets around to testing it against the new one. That's what scopes is for — a fallback map keyed by the path of the importing module, checked before the top-level imports:

{
  "imports": {
    "lodash-es/": "https://esm.sh/lodash-es@4.17.22/"
  },
  "scopes": {
    "/admin/": {
      "lodash-es/": "https://esm.sh/lodash-es@4.17.19/"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Every module under /admin/ resolves lodash-es/* to the pinned version; everything else gets the current one. If a specifier matches more than one scope path, the browser picks the longest — the most specific one wins, the same way specificity works everywhere else on the platform.

Three things that will bite you

Order matters — a lot. The import map has to appear before any module script that relies on it. Put it late in <head>, after a <script type="module"> has already started resolving imports, and you've written a JSON block that does nothing.

You get one per page, for now. Declare two <script type="importmap"> tags and current browsers will register the first and ignore the second with a console warning — the spec's story on merging multiple import maps is still catching up across engines. Plan your mappings as one block, not several you'll combine later.

src is a Chrome-only shortcut today. You can point an import map at an external JSON file with <script type="importmap" src="/import-map.json"> — but as of now, Firefox and Safari only accept the inline form. If you want the map to work everywhere, write the JSON directly inside the <script> tag, not in a file you link to.

Where this actually earns its keep

Import maps aren't a bundler replacement — you still don't get tree-shaking, minification, or a single merged file out of one. What they replace is the specific pain of coordinating dependency URLs by hand: no-build multi-page sites, quick prototypes that don't want a node_modules yet, and micro-frontends where two independently-deployed teams need to agree on one shared React instance without agreeing on a build tool. If your app already ships through Vite or webpack, you get this exact benefit — one place to change a version — from the bundler config instead, and there's no reason to add a second mechanism doing the same job.

But if you've ever pasted a CDN URL into a file and thought "I'll deal with the version number later," this is the later. Open your dev tools console right now and try import("lodash") on any page without an import map — read the error it gives you, then imagine one JSON block making it resolve instead.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

🧠 Test yourself

Think it clicked? Take the 9-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

The takeaway

A bare specifier like "lodash" means nothing to a browser on its own — it needs a bundler to rewrite it, or an import map to tell it where to look. The map is one JSON block: imports for the default resolution, scopes for path-specific overrides, trailing slashes for prefix matching, and it has to load before the first module that needs it. Next time you catch yourself duplicating a CDN URL across files, that's the smell import maps were built to fix.

Have you shipped a no-build app that outgrew hardcoded CDN imports — and if so, what finally broke first?


Thanks for reading! Let's stay connected:

Top comments (0)