Skip to content
JavaScript js modules 4 min read

Modules in the Browser

Browsers have shipped native ES module support since 2017, which means you can use import and export directly in the page without a bundler. The entry point is a single attribute: <script type="module">. That one attribute changes how the script is fetched, parsed, and scoped, so understanding its defaults is the difference between code that “just works” and a confusing CORS or this-is-undefined surprise.

Loading a module script

A module script is declared with type="module". You can point it at an external file or write the module inline.

<!-- External module -->
<script type="module" src="/js/app.js"></script>

<!-- Inline module -->
<script type="module">
  import { greet } from '/js/greet.js';
  greet('World');
</script>

Inside app.js, you write standard ESM. The browser resolves each import by issuing its own network request, following the dependency graph until everything is loaded.

// /js/greet.js
export function greet(name) {
  console.log(`Hello, ${name}!`);
}

Output:

Hello, World!

How module scripts differ from classic scripts

The behavior of type="module" differs from a classic <script> in several important ways. These are not opt-in flags — they are always on for module scripts.

BehaviorClassic <script><script type="module">
Execution timingBlocks parsing (unless defer/async)Always deferred
Strict modeSloppy by defaultAlways strict
Top-level scopeShares global scopePer-module scope
this at top levelwindowundefined
Cross-origin fetchAllowedRequires CORS
Re-executionRuns every time it appearsRuns once per URL

Deferred by default

A module script behaves as though it has the defer attribute. It downloads in parallel with HTML parsing but does not execute until the document has been fully parsed, and multiple module scripts execute in document order. You almost never need to wait for DOMContentLoaded inside a module — the DOM is already available.

// Runs after the document is parsed; the element exists.
document.querySelector('#app').textContent = 'Ready';

Adding async to a module script overrides the deferred behavior: it will execute as soon as it (and its dependencies) finish downloading, without waiting for the rest of the document or preserving order.

Strict and scoped

Every module runs in strict mode automatically — no "use strict" directive needed. Top-level variables are scoped to the module rather than leaking onto window, and this at the top level is undefined instead of the global object. Sharing state between modules is done explicitly through import/export, not through globals.

// In a module:
const apiKey = 'abc123'; // NOT window.apiKey
console.log(this);       // undefined

CORS applies

Module scripts are fetched with CORS semantics. A cross-origin module must be served with an appropriate Access-Control-Allow-Origin header, and loading from file:// is blocked — you need an HTTP server even for local development. Spin one up quickly:

npx serve .
# or
python3 -m http.server 8080

Module specifiers

The string you pass to import is the module specifier. In the browser, specifiers must be a valid URL or a relative/absolute path — a leading /, ./, or ../, or a full https:// URL. A bare specifier like import _ from 'lodash' does not work natively, because the browser has no idea where lodash lives.

import { greet } from './greet.js';      // relative — OK
import { config } from '/js/config.js';  // absolute path — OK
import dayjs from 'https://esm.sh/dayjs'; // full URL — OK
import _ from 'lodash';                   // bare — fails without an import map

Always include the file extension (./greet.js, not ./greet). The browser does not guess extensions the way Node or bundlers sometimes do.

Import maps

An import map lets you use bare specifiers in the browser by mapping a name to a URL. Declare it with <script type="importmap"> and it must appear before any module script that relies on it.

<script type="importmap">
{
  "imports": {
    "lodash": "https://esm.sh/lodash-es",
    "dayjs": "https://esm.sh/[email protected]",
    "@utils/": "/js/utils/"
  }
}
</script>

<script type="module">
  import { capitalize } from 'lodash';
  import dayjs from 'dayjs';
  import { formatDate } from '@utils/date.js'; // resolves to /js/utils/date.js

  console.log(capitalize('hello'));
  console.log(formatDate(dayjs()));
</script>

Keys that end in / define a prefix mapping, so @utils/date.js resolves to /js/utils/date.js. This is how you alias whole folders or pin specific dependency versions without rewriting every import in your source files.

A complete self-contained example

<!doctype html>
<html lang="en">
<body>
  <p id="out"></p>

  <script type="module">
    // Top-level await is allowed inside module scripts.
    const greet = (name) => `Hello, ${name}!`;
    const messages = ['Ada', 'Linus', 'Grace'].map(greet);
    document.querySelector('#out').textContent = messages.join(' ');
  </script>
</body>
</html>

Output:

Hello, Ada! Hello, Linus! Hello, Grace!

Best Practices

  • Always serve modules over HTTP(S); file:// is blocked by CORS rules.
  • Include explicit file extensions in every specifier — the browser will not add them for you.
  • Rely on the built-in deferred behavior instead of wiring up DOMContentLoaded.
  • Use an import map to manage bare specifiers and pin dependency versions in one central place.
  • Place the import map before any module script that depends on it.
  • Provide a <script nomodule> fallback only if you must support legacy browsers; otherwise it is unnecessary in 2026.
Last updated June 1, 2026
Was this helpful?