JavaScript Modules

Reviewed & published by Brayan K

A JavaScript module is a self-contained file that exposes its code to other files using export and pulls in code from elsewhere using import, keeping each part of an app organized and reusable.

Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

💡 Running Code Locally: While this online editor runs real JavaScript, some advanced examples (like fetch to external APIs) may have limitations. For the best experience:

🧩 Real-World Analogy: LEGO Bricks

Think of JavaScript modules like LEGO bricks. Each brick (module) is self-contained with a specific shape and purpose. You can combine different bricks to build complex structures (applications), and if one brick breaks, you just replace that one instead of rebuilding everything. Just like LEGO sets come in organized boxes with labeled compartments, modules keep your code organized and manageable.

📋 Module Syntax Quick Reference:

ActionSyntaxWhen to Use
Named Exportexport const nameMultiple exports from one file
Default Exportexport defaultOne main thing per file
Named ImportImport specific items
Default Importimport x fromImport the main export
Dynamic Importawait import()Load modules on demand

JavaScript modules completely changed how modern applications are built, transforming a language that once relied on global variables into a structured, scalable ecosystem capable of powering complex frontends, backend servers, mobile apps, and enterprise systems. ES6 modules introduced a standardised way to split code into files, share functions or classes, and control visibility.

Instead of dumping everything into a single script tag, modules allow you to create isolated, reusable units that behave predictably without polluting the global scope.

⚠️ Why the Run button will not work on most snippets here: modules are the one JavaScript feature that cannot exist inside a single scratch file. import and export are only legal in a file the browser has loaded as a module — which needs real separate files served over HTTP and a <script type="module"> tag. Press Run on any snippet below and the editor gives you one of these, correctly:

Read those snippets for the syntax and run them on your own machine. The three exercises in this lesson take a different route: they build a working module loader in plain JavaScript, so you can watch caching, module scope and live bindings actually happen.

🔥 Basic Exports and Imports

At the core of the module system are exports and imports. Exports decide what parts of a file are accessible to other files. Imports allow you to pull those exported values into the environment where you need them.

The simplest example is a utility file exporting a function:

// math.js
export function add(a, b) {
  return a + b;
}

// app.js
import { add } from "./math.js";
console.log(add(5, 7)); // 12

This may look basic, but behind the scenes it activates the module loader, which performs dependency resolution, module graph construction, caching, and execution ordering before any code runs.

🔥 Named Exports vs Default Exports

Different export styles give you different architectural patterns. A file can have multiple named exports, which is best when offering multiple utilities:

export const PI = 3.14159;
export function area(r) { return PI * r * r; }
export function circumference(r) { return 2 * PI * r; }

Or you can export a single default:

export default class User {
  constructor(name) { this.name = name; }
}

// Then import:
import User from "./User.js";

Named imports use curly braces because they map exactly to identifiers. Default imports do not because they represent a single primary value from that file. Named imports must match export names unless aliased:

import { area as circleArea } from "./circle.js";

🔥 Live Bindings — A Critical Concept

One key property is that imports are live bindings. An import is not a copy of a value taken at load time — it is a read-only window onto the exporting module's variable. If the module that owns the variable reassigns it, every importer sees the new value immediately.

Two consequences people mix up. First, reassignment inside the owning module is visible to importers — that is exactly what "live" means, and it is what makes the example below work. Second, an importer cannot assign to an imported name itself: count = 5 in the importing file is a TypeError, because the binding is read-only from the outside. (Mutating an exported object, as the example below does with state.count++, is a different thing entirely — that is just two files sharing one object reference, and would work the same way without modules.)

// store.js
export const state = { count: 0 };

// increment.js
import { state } from "./store.js";
state.count++;

// app.js
import { state } from "./store.js";
console.log(state.count); // 1 ✔ updates live

This is the foundation of reactive libraries like Svelte, SolidJS, and Vue's Composition API, which rely on live module bindings to track changes.

Worked example: build the module loader yourself

"The module loader resolves dependencies, caches records and orders execution" is a sentence that means nothing until you have written one. It is about twenty lines. The version below is real, runnable JavaScript — no import keyword in sight — and it reproduces the four behaviours that matter: private module scope, run-once execution, caching, and live bindings.

// ---- A miniature module system ---------------------------------------
// When the browser meets an import it does four things: find the file, run
// it EXACTLY ONCE, remember the result, and hand out live references to
// whatever it exported. That is the whole job, and it fits in 20 lines.

const files = {                    // stands in for files on disk
  'counter.js': function (exports, require) {
    console.log('  [counter.js body runs]');
    let count = 0;                 // MODULE SCOPE: nothing outside can see this
    exports.increment = () => { count += 1; };
    // A live binding: importers read the CURRENT value, not a copy taken
    // at import time. Real ESM gives you this for free.
    Object.defineProperty(exports, 'count', { get: () => count });
  },

  'report.js': function (exports, require) {
    console.log('  [report.js body runs]');
    const counter = require('counter.js');       // its own import
    exports.show = () => console.log('  count is now ' + counter.count);
  }
};

const cache = {};                  // the module cache: name -> exports object

function require(name) {
  if (name in cache) {             // seen before? hand back the SAME object
    console.log('  (cache hit: ' + name + ')');
    return cache[name];
  }
  const exports = {};
  cache[name] = exports;           // cache BEFORE running the body - this is
                                   // what lets circular imports half-work
  files[name](exports, require);   // run the body: once, ever
  return exports;
}

console.log('first require of report.js:');
const a = require('report.js');

console.log('second require of report.js:');
const b = require('report.js');
console.log('same module object? ' + (a === b));

const counter = require('counter.js');
a.show();
counter.increment();
counter.increment();
a.show();
console.log('live binding read directly: ' + counter.count);
console.log('can outside code see the private variable? ' + (typeof count));

// ✅ Expected output:
// first require of report.js:
//   [report.js body runs]
//   [counter.js body runs]
// second require of report.js:
//   (cache hit: report.js)
// same module object? true
//   (cache hit: counter.js)
//   count is now 0
//   count is now 2
// live binding read directly: 2
// can outside code see the private variable? undefined
//
// Four things just happened, and all four are real ESM behaviour:
// - counter.js printed its body line ONCE, even though two different places
//   imported it. Modules are singletons.
// - report.js got the same exports object the second time (a === b).
// - 'count is now 0' then 'count is now 2' from the SAME show() function:
//   report.js never re-imported anything, it is reading a live binding.
// - typeof count is 'undefined' out here. The variable exists, but only
//   inside its module. That is module scope, and it is why modules replaced
//   the old habit of hanging everything off window.

🔥 How Browsers Load Modules

When you load a script with:

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

The browser downloads app.js, parses its import statements, and then recursively fetches all modules before executing anything. This behaviour ensures correct dependency ordering but also means modules load via separate network requests — which is why bundling exists.

Without bundling, an app with 200 imports would make 200 HTTP requests. While HTTP/2 and HTTP/3 minimise this overhead, bundlers still optimise dependency trees to reduce size.

🔥 Understanding Bundlers

Tools like Webpack, Rollup, Vite, esbuild, and Parcel take your ES6 modules, resolve import paths, tree-shake unused exports, minify code, inline small assets, rewrite URL references, and output a single (or few) build files.

A very simple bundler configuration for Rollup looks like:

// rollup.config.js
export default {
  input: "src/main.js",
  output: {
    file: "dist/bundle.js",
    format: "esm",
    sourcemap: true
  }
};

Rollup is tree-shaking-first, making it excellent for libraries. Webpack is more configurable and supports loaders for CSS, images, fonts, and advanced optimisation.

Bundling also solves the difference between "bare imports" and relative paths. Native ES modules require relative paths like ./utils.js unless running in Node or using an import map. Bundlers allow simplifying paths:

import axios from "axios";

// The bundler rewrites this to the correct file inside node_modules

🔥 Module Scope

Every module has its own top-level scope, so variables declared at the top of a module are not available globally. This solves many of the old problems seen in large applications where different files accidentally overwrote identifiers.

🔥 Dynamic Imports & Code Splitting

You can dynamically import modules based on user action:

async function loadChart() {
  const { renderChart } = await import("./chart.js");
  renderChart();
}

This enables code-splitting — loading only the necessary code when needed, which massively improves performance. This technique is used everywhere: YouTube loads comments only when scrolling, Amazon loads product recommendations dynamically, and Meta loads notification panels as separate modules.

🔥 Tree-Shaking: When It Works and When It Fails

// math.js
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }

// If you only import add, bundlers remove multiply
export default {
  add(a, b) { return a + b; },
  multiply(a, b) { return a * b; }
};

// Nothing can be tree-shaken — the entire object must remain

This is why all major libraries (React, Lodash-es, RxJS, date-fns) use pure named exports.

🔥 Module Caching

When a module is first imported, the engine creates a Module Record — containing its exports, its execution state, and pointers to dependent modules. After execution, this record is stored in cache so other imports return the same objects.

// config.js
console.log("Config loaded");
export const config = { theme: "dark" };

// Importing from 10 different files prints "Config loaded" only once

This means modules are effectively singletons. It's ideal for configuration, shared state, or global utilities, but dangerous for objects that must be reinitialised per user session.

🎯 Your turn: make the cache work

Here is the loader again with the two cache lines removed. Both user.js and orders.js import config.js. Get the cache right and config.js announces itself once; get it wrong and it announces itself twice, which in a real app means two separate configuration objects and a bug that takes a day to find.

// 🎯 YOUR TURN — fill in the blanks marked with ___

const files = {
  'config.js': function (exports) {
    console.log('  [config.js body runs]');
    exports.apiUrl = 'https://api.example.com';
  },
  'user.js': function (exports, require) {
    console.log('  [user.js body runs]');
    const config = require('config.js');
    exports.endpoint = config.apiUrl + '/users';
  },
  'orders.js': function (exports, require) {
    console.log('  [orders.js body runs]');
    const config = require('config.js');
    exports.endpoint = config.apiUrl + '/orders';
  }
};

const cache = {};

function require(name) {
  // 1) Already loaded? Return the object from last time; do NOT run it again.
  if (___) {                       // 👉 replace ___ with  name in cache
    return cache[name];
  }

  const exports = {};

  // 2) Put it in the cache BEFORE running the body, not after.
  ___;                             // 👉 replace ___ with  cache[name] = exports

  files[name](exports, require);
  return exports;
}

const user = require('user.js');
const orders = require('orders.js');
console.log('user endpoint:   ' + user.endpoint);
console.log('orders endpoint: ' + orders.endpoint);
console.log('same config object both times? ' + (require('config.js') === require('config.js')));

// ✅ Expected output once both blanks are filled:
//   [user.js body runs]
//   [config.js body runs]
//   [orders.js body runs]
// user endpoint:   https://api.example.com/users
// orders endpoint: https://api.example.com/orders
// same config object both times? true
//
// Note what is NOT there: a second '[config.js body runs]'. orders.js asked
// for config too, and got the cached object.
// Seeing config.js run twice, or 'same config object? false'? Blank 1 is
// not finding the entry. Blank 2 belongs BEFORE the files[name](...) call —
// caching afterwards is what makes circular imports loop forever.

🔥 Circular Dependencies — The Hidden Problem

Circular dependencies create hidden runtime errors:

// a.js
import { b } from "./b.js";
export const a = 1;
b();

// b.js
import { a } from "./a.js";
export const b = () => console.log(a); // ❌ a may be undefined

Professional architecture avoids cycles using a layered dependency model:

🔥 Common Path Mistakes

❌ Mistake 1: Forgetting file extensions in browser ESM

import utils from "./utils"; // ❌ error in browser
import utils from "./utils.js"; // ✔ correct

❌ Mistake 2: Using absolute paths without import maps

import { helper } from "/utils/helper.js"; // works
import { helper } from "utils/helper.js"; // ❌ browser can't resolve

❌ Mistake 3: Mixing CommonJS & ESM

const express = require("express"); // ❌ if project is "type": "module"

❌ Mistake 4: Dynamic import with incorrect path resolution

await import("./" + fileName); // ❌ breaks bundlers (can't statically analyze)

🔥 The "Barrel File" Pattern

A barrel file (index.js) re-exports everything inside a folder:

src/
  utils/
    format.js
    math.js
    index.js
// index.js
export * from "./format.js";
export * from "./math.js";

// Now your imports become clean:
import { formatCurrency, clamp } from "@/utils";

Barrels create a structured module ecosystem that tools interpret as organised, high-value content.

🎯 Mini-Challenge: write the barrel

export * from "./math.js" is not magic — it means "import that module and re-expose everything it exported as my own exports". Prove it by writing the barrel body in the loader. Two modules go in, one tidy object comes out.

const files = {
  'math.js': function (exports) {
    exports.add = (a, b) => a + b;
    exports.double = n => n * 2;
  },
  'strings.js': function (exports) {
    exports.shout = s => s.toUpperCase() + '!';
    exports.initials = s => s.split(' ').map(w => w[0]).join('');
  },

  // 🎯 MINI-CHALLENGE: write the barrel file body
  // It should:
  //   1. require('math.js') and require('strings.js')
  //   2. copy every name from BOTH onto its own 'exports' object, so that
  //      a caller can reach add, double, shout and initials through it
  // Hint: Object.assign(target, a, b) copies a's and then b's own keys
  //       onto target and returns target.
  'index.js': function (exports, require) {
    // your code here
  }
};

const cache = {};
function require(name) {
  if (name in cache) return cache[name];
  const exports = {};
  cache[name] = exports;
  files[name](exports, require);
  return exports;
}

const lib = require('index.js');
console.log('add(2, 3)             = ' + lib.add(2, 3));
console.log('double(7)             = ' + lib.double(7));
console.log('shout("hello")        = ' + lib.shout('hello'));
console.log('initials("Ada Lovelace") = ' + lib.initials('Ada Lovelace'));
console.log('names on the barrel: ' + Object.keys(lib).join(', '));

// ✅ Expected output when your barrel is right:
// add(2, 3)             = 5
// double(7)             = 14
// shout("hello")        = HELLO!
// initials("Ada Lovelace") = AL
// names on the barrel: add, double, shout, initials
//
// ❌ TypeError: lib.add is not a function means nothing was copied onto
// exports. Watch out for the classic mistake: 'exports = { ...math }'
// replaces your local variable and the loader never sees it. You must add
// TO the exports object you were handed, not point the name at a new one.
//
// Worth knowing before you use barrels for real: this convenience has a
// cost. Importing one name from a barrel pulls the whole barrel into the
// module graph, and if any of those modules has a side effect, tree-shaking
// cannot drop it. That is why large codebases often import from the exact
// file rather than the folder.

🔥 Namespace Imports

Namespace imports create a frozen module object:

import * as math from "./math.js";
math.PI = 3; // ❌ TypeError — namespace objects are read-only

This prevents accidental corruption of shared modules and enables optimisations inside bundlers and JIT engines.

🔥 Professional Module Patterns

1. "Pure Module" Pattern

No top-level side effects. Everything is callable. Best for library design.

2. "Service Module" Pattern

export const AuthService = new Auth();

3. "Facade Pattern" for Large Apps

Modules act as simplified interfaces hiding complexity.

// services/api/index.js
export * from "./auth.js";
export * from "./users.js";
export * from "./products.js";

🔥 Module Pattern Architecture For Scalable Projects

A professional project uses folders like:

src/
  api/
  components/
  hooks/
  services/
  utils/
  state/
  config/

Each folder exposes a single index.js:

export * from "./request.js";
export * from "./auth.js";
export * from "./users.js";

This creates a clean, predictable import system:

import { login, register } from "@/api";

🔥 Security Considerations

✔ Modules prevent global namespace pollution

✔ Prevent leaking state accidentally

✔ Dynamic imports can sandbox untrusted code

❌ Never dynamically import unvalidated user-provided paths

❌ Never put secrets in exported constants

❌ Avoid top-level API calls inside modules

🔥 Bundling Pitfalls Beginners Always Hit

Correct code for bundler-friendly apps:

export function getUser(id) { /* ... */ }
export function deleteUser(id) { /* ... */ }

🎯 Key Takeaways

Understanding ES6 modules isn't just about syntax — it's about understanding how your entire application architecture behaves under real production conditions. Every decision you make about import structure, export styles, bundling configuration, module boundaries, and dynamic loading impacts performance, caching behaviour, security, and scalability.

Practice quiz

What does a JavaScript module use to expose its code to other files?

  • require
  • global
  • export
  • window

Answer: export. A module exposes code with export and pulls in code from elsewhere with import.

When should you prefer a default export?

  • When a file has one main thing to export
  • When offering many utilities
  • Never
  • Only for constants

Answer: When a file has one main thing to export. Default export is for the one main thing per file; named exports suit multiple utilities.

Why do named imports use curly braces while default imports do not?

  • It is just style
  • Braces make imports faster
  • Default imports are deprecated
  • Named imports map exactly to identifiers; a default is a single primary value

Answer: Named imports map exactly to identifiers; a default is a single primary value. Named imports map to specific exported identifiers, so they need braces; defaults are one value.

What does it mean that imports are 'live bindings'?

  • They are copies frozen at import time
  • Changes to an exported value are seen across all importing modules
  • They reload on every access
  • They only work in Node

Answer: Changes to an exported value are seen across all importing modules. Imports are live bindings, so a mutation to an exported value is visible to all modules using it.

What does tree-shaking do?

  • Removes unused exports from the bundle
  • Reorders imports alphabetically
  • Minifies variable names
  • Caches network requests

Answer: Removes unused exports from the bundle. Tree-shaking removes unused exports; if you only import add, the bundler drops multiply.

Why can a default-exported object block tree-shaking?

  • It is always larger
  • Default exports aren't bundled
  • The entire object must remain since individual members can't be removed
  • It causes circular dependencies

Answer: The entire object must remain since individual members can't be removed. Exporting an object as default means nothing inside it can be tree-shaken; the whole object stays.

What do dynamic imports with await import() enable?

  • Synchronous loading
  • Code-splitting — loading modules on demand
  • Global variables
  • Removing all imports

Answer: Code-splitting — loading modules on demand. Dynamic imports load modules only when needed, enabling code-splitting for better performance.

Because modules are cached after first import, they effectively behave as what?

  • New instances each time
  • Global variables
  • Copies
  • Singletons

Answer: Singletons. The Module Record is cached, so every import returns the same objects — modules are singletons.

What does each module have that prevents global namespace pollution?

  • A shared global scope
  • Its own top-level module scope
  • Automatic var hoisting
  • A required IIFE

Answer: Its own top-level module scope. Every module has its own top-level scope, so top-level variables aren't global.

How does professional architecture avoid circular dependencies?

  • By using only default exports
  • By disabling caching
  • With a layered dependency model that never imports upwards
  • By using require instead of import

Answer: With a layered dependency model that never imports upwards. A layered model like components → services → utils → constants, never importing upward, avoids cycles.

Continue this course