ES Modules Logo ESModules.com

Module Resolution

How ES Modules are resolved from specifiers to actual files.

Module Specifiers

Relative Paths

import utils from './utils.js';
import config from '../config.js';

Absolute URLs (Browser)

import lodash from 'https://cdn.skypack.dev/lodash';

Bare Specifiers (Node.js)

import express from 'express';
import { readFile } from 'fs/promises';

Package.json "exports"

Control which files are exposed from your package:

{
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js",
    "./package.json": "./package.json"
  }
}

Conditional Exports:

{
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  }
}

File Extensions

Unlike CommonJS, ES Modules require explicit file extensions:

❌ Won't Work

import utils from './utils';

✅ Correct

import utils from './utils.js';

Import Maps

Import maps allow you to control how bare module specifiers are resolved in the browser, enabling npm-style imports without a bundler. Universally supported in all modern browsers since 2023.

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

<script type="module">
  // Now you can use bare specifiers!
  import { debounce } from 'lodash';
  import React from 'react';
  import { format } from '@/utils/format.js';
</script>

Scoped Imports

Different modules can resolve the same specifier to different URLs:

<script type="importmap">
{
  "imports": {
    "lodash": "https://esm.sh/[email protected]"
  },
  "scopes": {
    "/legacy/": {
      "lodash": "https://esm.sh/[email protected]"
    }
  }
}
</script>

Modules loaded from /legacy/ will get lodash v3, all others get v4.

Browser Support

Universal Support (2024+):

Chrome 89+, Firefox 108+, Safari 16.4+, Edge 89+. Covers 95%+ of global browser usage. No polyfill needed for modern browser targets.

Self-Referencing Imports

The imports field in package.json defines private mappings for the package itself, using the # prefix:

// package.json
{
  "imports": {
    "#utils/*": "./src/utils/*.js",
    "#db": {
      "node": "./src/db-node.js",
      "default": "./src/db-browser.js"
    }
  }
}

// In your code - clean imports without relative paths
import { format } from '#utils/format';
import { connect } from '#db';

Benefits:

  • Eliminates long relative paths (../../../utils)
  • Supports conditional resolution (node vs browser)
  • Works in Node.js 16+ without any tooling
  • Cannot be imported by other packages (private)

How Module Resolution Works

When the JavaScript engine encounters an import statement, it goes through four distinct phases:

1. Construction (Parsing)

Find, download, and parse the module file into a Module Record. Each module is only fetched once - subsequent imports use the cached record.

2. Instantiation (Linking)

Allocate memory for all exports. Connect imports to their corresponding exports (live bindings). No code is executed yet.

3. Evaluation (Execution)

Execute module code to fill in the allocated memory with actual values. Each module is evaluated exactly once, in dependency order.

4. Caching

The evaluated module is stored in the Module Map. Any future import of the same URL returns the cached module instance.

Continue Learning