Modules, introduction

Once a program outgrows a single file, you want to break it apart. A file that holds one class, or a group of related functions, or the config for one feature — that unit is a module. Splitting code this way keeps each piece small enough to reason about and lets you reuse the same file in more than one place.

For most of JavaScript’s life there was no built-in way to do this. Early scripts were short, so nobody missed it. As pages grew into applications, the community filled the gap with libraries and conventions for loading code on demand.

A few you’ll still bump into in older projects:

  • AMD — one of the earliest module systems, born with the require.js loader.
  • CommonJS — the system built for Node.js on the server. It’s the require() / module.exports style.
  • UMD — a wrapper meant to work everywhere, compatible with both AMD and CommonJS.

These are legacy now, but they haven’t vanished — plenty of packages still ship them. The language-level system arrived in the 2015 standard, matured over the following years, and today runs in every major browser and in Node.js. That native system is what this course covers.

AMD
require.js
CommonJS
Node.js
UMD
both
ES Modules
2015, native
Module systems over time — from library conventions to a language feature.

What is a module?

A module is a file. One script file, one module. That’s the whole definition.

Modules pull functionality from one another through two keywords, export and import, which let code in one file call functions defined in another:

  • export marks the variables and functions in a file that outside code is allowed to use.
  • import brings those marked pieces into the current file.

Say greet.js exports a function:

// 📁 greet.js
export function greet(user) {
  alert(`Welcome, ${user}!`);
}

Another file can import it and put it to work:

// 📁 main.js
import {greet} from './greet.js';

alert(greet); // function...
greet('Maya'); // Welcome, Maya!

The import line finds ./greet.js by a path relative to main.js, then binds the exported greet to a local name you can call.

📁 greet.js
export function greet(u) { … }
greet
───→
📁 main.js
import {greet} from ‘./greet.js’
greet(‘Maya’)
export exposes a name; import pulls it into another file by relative path.

To run this in a browser, you have to tell the page that a script is a module. Modules understand keywords and rules that plain scripts don’t, so the browser needs the hint. You give it with the type attribute: <script type="module">.

The demo below simulates that wiring in one sandbox — greet.js provides the function, main.js calls it. Type a name and run main.js:

interactivegreet.js exports, main.js imports

Once it sees type="module", the browser fetches the imported files (and anything those files import in turn), evaluates them, and then runs your script.

Core module features

So what actually changes when a file becomes a module rather than an ordinary script? Several things, and the ones below hold everywhere — in the browser and on the server alike.

Always “use strict”

Modules run in strict mode, always. You never write "use strict" at the top; it’s on by default and can’t be turned off. So an accidental assignment to a name you never declared throws instead of silently creating a global:

<script type="module">
  a = 5; // error
</script>

That’s a genuine advantage. The sloppy-mode footguns — silent globals, silent failures on read-only properties, and the rest — are gone before you write a line.

Toggle the checkbox to feel the difference. Assigning to a name that was never declared is a caught error under strict mode (how every module runs), but silently creates a global without it:

interactiveStrict mode catches an undeclared assignment

Module-level scope

Every module gets its own top-level scope. Variables and functions declared at the top of one module are invisible to every other module. They don’t leak into a shared global namespace.

Below, two scripts load, and hello.js reaches for a user variable that was declared in user.js. It fails, because each file is a separate module with a separate scope — it fails when you run hello.js:

interactivehello.js can't see user.js's private variable

The fix isn’t to make the variable global. It’s to export what should be shared and import it where it’s needed:

  • user.js exports user.
  • hello.js imports it from user.js.

That’s the whole idea — you wire modules together with import/export instead of leaning on shared globals.

Here it is done right — user.js exports the value and hello.js imports it. Edit the exported value and run hello.js:

interactivehello.js imports user from user.js
📁 user.js
let user = “Maya”
// private here
📁 hello.js
usernot defined
// separate scope
Each module's top-level names stay private unless exported.

In the browser this applies per <script type="module"> tag too. Two module scripts on the same page each have their own top-level scope and can’t see each other’s variables:

<script type="module">
  // This variable lives only inside this module script
  let user = "Maya";
</script>

<script type="module">
  alert(user); // Error: user is not defined
</script>

A module is evaluated once, on first import

Import the same module from ten different files and its top-level code still runs exactly once — the first time it’s imported. After that, the exports it produced are handed to every later importer as-is.

That single run has real consequences. Two examples show why.

First, side effects. If a module does something visible while it loads, like popping an alert, importing it repeatedly triggers that only on the first import:

// 📁 alert.js
alert("Setup ran once!");
// Import the same module from two different files

// 📁 1.js
import './alert.js'; // Setup ran once!

// 📁 2.js
import './alert.js'; // (shows nothing)

The second import is silent — the module already ran, so its top-level code won’t run again.

The takeaway: top-level module code is for one-time setup — creating the module’s internal data, wiring things up. Anything you want to happen more than once belongs in an exported function, the way greet was earlier.

Now a subtler case. Suppose a module exports an object:

// 📁 admin.js
export let admin = {
  name: "Maya"
};

Because the module runs once, that admin object is created once. Every importer receives that same object — not a fresh copy each time, the identical reference:

// 📁 1.js
import {admin} from './admin.js';
admin.name = "Raj";

// 📁 2.js
import {admin} from './admin.js';
alert(admin.name); // Raj

// 1.js and 2.js point at the same admin object
// A change made in 1.js is visible in 2.js

When 1.js sets admin.name to "Raj", then 2.js reads that new value. One object, shared. Both files hold a reference to the same thing in memory, so a mutation through one is seen through the other.

📁 1.js
import {admin}
📁 2.js
import {admin}
──→
admin.js (ran once)
admin = { name: “Raj” }
One evaluation, one object — every importer holds the same reference.

See it live. Both 1.js and 2.js hold the same admin object, so a change made through one is visible through the other:

interactiveOne object, shared by every importer

A module can offer generic functionality that needs a bit of setup before it works. Authentication, for instance, needs credentials. So the module exports a configuration object and lets outside code fill it in.

The pattern goes:

  1. The module exports something to configure — often a config object.
  2. The first importer initializes it, writing to its properties. Usually the app’s top-level startup script does this.
  3. Later importers just use the module, now already configured.

Here admin.js provides some functionality but expects the credentials to arrive from outside via a config object:

// 📁 admin.js
export let config = { };

export function greet() {
  alert(`Dashboard ready for ${config.user}!`);
}

config starts empty (it could carry defaults instead), waiting to be filled.

In init.js — the first script the app runs — we import config and set config.user:

// 📁 init.js
import {config} from './admin.js';
config.user = "Raj";

Now admin.js is configured. Any later importer can call its functions and see the value that was set:

// 📁 another.js
import {greet} from './admin.js';

greet(); // Dashboard ready for Raj!

import.meta

import.meta is an object holding metadata about the current module. What’s in it depends on where the code runs. In the browser it carries the script’s URL, or the page’s URL for an inline script:

<script type="module">
  alert(import.meta.url); // script URL
  // for an inline script — the URL of the current HTML page
</script>

Bundlers and Node.js populate import.meta with their own fields, so treat its exact contents as environment-specific.

In a module, top-level “this” is undefined

A small detail, included for completeness. At the top level of a module, this is undefined.

Compare that with a plain, non-module script, where top-level this is the global object:

<script>
  alert(this); // window
</script>

<script type="module">
  alert(this); // undefined
</script>

Browser-specific features

Scripts with type="module" also behave differently from regular scripts in a handful of browser-only ways. If this is your first read, or you don’t run JavaScript in a browser, feel free to skip ahead and come back later.

Module scripts are deferred

Module scripts are always deferred — they behave as if they carried the defer attribute (covered in the chapter on script async/defer), and this holds for both external and inline module scripts.

Concretely:

  • An external module script — <script type="module" src="..."> — doesn’t block HTML parsing. It downloads in parallel with the rest of the page.
  • The script waits until the whole HTML document is parsed before it runs, even if it’s tiny and finished downloading long before the HTML did.
  • Document order is preserved: a module script earlier in the page runs before one that comes later.

A useful consequence: a module script always “sees” the complete page, including elements that appear after it in the markup.

<script type="module">
  alert(typeof button); // object: this script can 'see' the button below
  // modules are deferred, so this runs after the whole page is parsed
</script>

Compare to the regular script below:

<script>
  alert(typeof button); // undefined — this script can't see elements below it
  // regular scripts run right away, before the rest of the page is parsed
</script>

<button id="button">Button</button>

Note the ordering: the second script actually runs first. You’ll see undefined, then object. The regular script executes immediately, mid-parse, when button doesn’t exist yet. The module waits for the full document, by which time button is there.

parse HTML →
module: queued
regular: runs now → undefined
page ready →
module: runs now → object
Regular script runs mid-parse; the deferred module waits for the full page.

One design implication: with modules the page appears while the JavaScript is still loading. A visitor might see the interface before it’s interactive, so some features won’t respond yet. Add a loading indicator, or otherwise make sure the half-ready state doesn’t confuse anyone.

async works on inline scripts

For regular scripts, async only has an effect on external ones. An async script runs the moment it’s ready, ignoring other scripts and the document’s parsing state.

For module scripts, async works on inline scripts too.

The inline module below carries async, so it waits for nothing. It performs its import (fetching ./analytics.js) and runs as soon as that’s ready — regardless of whether the document has finished or other scripts are still pending:

<!-- all dependencies (analytics.js) are fetched, then the script runs -->
<!-- it doesn't wait for the document or for other <script> tags -->
<script async type="module">
  import {counter} from './analytics.js';

  counter.count();
</script>

That’s a good fit for self-contained work with no dependency on the page — counters, ads, document-level event listeners.

External scripts

External scripts with type="module" differ from regular ones in two ways:

  1. Two external module scripts with the same src execute only once. The duplicate is fetched-and-run only a single time:

    <!-- my.js is fetched and executed only once -->
    <script type="module" src="my.js"></script>
    <script type="module" src="my.js"></script>
  2. A module script fetched from a different origin (another site) needs CORS headers, as covered in the chapter Fetch: Cross-Origin Requests. The remote server must send an Access-Control-Allow-Origin header permitting the fetch, or the browser refuses to run the script:

    <!-- another-site.com must send Access-Control-Allow-Origin -->
    <!-- otherwise the script won't execute -->
    <script type="module" src="http://another-site.com/their.js"></script>

    That’s a security-by-default stance: cross-origin code doesn’t run unless the other server opts in.

No “bare” modules allowed

In the browser, an import path has to be a relative or an absolute URL. A module referenced by name alone, with no path, is called a bare module — and browsers reject it.

So this is invalid in the browser:

import {greet} from 'greet'; // Error, "bare" module
// it needs a path, e.g. './greet.js' or wherever the file lives

Some environments — Node.js, bundlers — do accept bare specifiers, because they have their own resolution rules (Node walks node_modules, for example) and hooks to tune them. Browsers don’t resolve bare names on their own. Import maps can teach a browser to map a bare name to a URL, but plain import still can’t take a bare specifier.

Compatibility, “nomodule”

Older browsers don’t recognize type="module" — a script of an unknown type is simply ignored. You can pair that with the nomodule attribute to serve a fallback:

<script type="module">
  alert("Runs in modern browsers");
</script>

<script nomodule>
  alert("Modern browsers understand both type=module and nomodule, so they skip this")
  alert("Old browsers ignore the type=module script above, but run this one.");
</script>

Modern browsers understand nomodule and skip that script; old browsers don’t understand it and run it. The two attributes together split your audience cleanly.

Build tools

In practice, raw browser modules are rarely shipped as-is. Teams usually run them through a bundler such as Webpack, Rollup, or esbuild, then deploy the output.

One upside of a bundler is control over how modules resolve — it can accept bare modules, and even treat CSS and HTML as importable modules.

Roughly, a bundler does this:

  1. Start from a “main” module — the one you’d otherwise put in <script type="module">.
  2. Trace its dependencies: its imports, then their imports, and so on down the graph.
  3. Emit a single file with every module inside (or a few files — that’s configurable), rewriting the native import calls into the bundler’s own wiring so it all still works. Special module types like CSS/HTML are handled too.
  4. Along the way it can apply optimizations:
    • Strip unreachable code.
    • Drop unused exports — this is tree-shaking.
    • Remove development-only statements like console and debugger.
    • Down-compile modern syntax to an older equivalent with a tool like Babel, for broader browser support.
    • Minify the result — whitespace gone, variable names shortened.

Because the bundler rewrites import / export into its own function calls, the bundled output contains none of them. It doesn’t need type="module" and can load as a plain script:

<!-- bundle.js came out of a tool like Webpack -->
<script src="bundle.js"></script>

Native modules work fine on their own, though. This course sticks with them, so there’s no bundler to set up here — you can add one later when a project calls for it.

Summary

The core ideas:

  1. A module is a file. For import / export to work in a browser you need <script type="module">. Module scripts differ from regular ones in that they are:
    • Deferred by default.
    • Able to use async on inline scripts.
    • Subject to CORS when loaded from another origin (domain, protocol, or port).
    • Run only once even if the same external src appears twice.
  2. Modules have their own local top-level scope and exchange functionality through import / export rather than globals.
  3. Modules are always in strict mode.
  4. Module code runs a single time. Its exports are created once and shared with every importer.

Each module implements something and exports it; you import it exactly where you need it, and the browser loads and evaluates everything for you. In production, a bundler like Webpack usually packs the modules together for performance and other reasons.

The next chapter digs into the details of export and import — the different forms they take and how to use each.