ESC

Type to search the knowledge base.

Module Patterns before ES Modules

IIFE, Revealing Module, AMD, CommonJS, UMD — how pre-ESM isolation worked and what still shows up in legacy bundles.

intermediate3 min read
  • javascript
  • module-patterns

Before import/export shipped everywhere, JS had one shared global scope in browsers. Names collided. Teams invented patterns to fake privacy and dependency graphs. You still read them in older libraries and interview “how would you isolate state?” questions.

The problem modules solve

  • Avoid polluting window
  • Hide private helpers
  • Declare dependencies explicitly
  • Load code in a controlled order

ES modules do this natively. Pre-ESM patterns approximate it with closures and conventions.

IIFE — instant privacy

var counter = (function () {
  var n = 0; // private
  return {
    inc() { return ++n; },
    value() { return n; },
  };
})();

counter.inc(); // 1
// no access to n from outside

An Immediately Invoked Function Expression creates a scope, runs once, returns a public API. This is the ancestor of the Revealing Module pattern.

Revealing Module

Same idea, named for returning only the methods you “reveal”:

var cart = (function () {
  var items = [];

  function add(item) { items.push(item); }
  function total() {
    return items.reduce((s, i) => s + i.price, 0);
  }

  return { add: add, total: total };
})();

Private state lives in the closure. Public surface is the returned object. No real privacy against determined callers who monkey-patch the returned methods’ closures… wait — they can’t reach items unless you leak it. Good enough for app code.

CommonJS (Node-style)

// math.js
const PI = 3.14159;
function area(r) { return PI * r * r; }
module.exports = { area };

// app.js
const { area } = require('./math');
  • Sync require
  • module.exports is the export object
  • One file, one module instance (cache)

Bundlers (webpack, Browserify) rewrote this for the browser by wrapping each file in a function and shipping a runtime require.

AMD (Asynchronous Module Definition)

// define(deps, factory)
define(['./math'], function (math) {
  return {
    circle(r) { return math.area(r); },
  };
});

Designed for browsers: load deps async, then run factory. RequireJS was the main loader. Verbose; mostly historical now.

UMD — “works everywhere”

A UMD wrapper detects the environment:

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    define(['exports'], factory);
  } else if (typeof module === 'object' && module.exports) {
    factory(module.exports);
  } else {
    factory((root.myLib = {}));
  }
})(typeof self !== 'undefined' ? self : this, function (exports) {
  exports.hello = () => 'hi';
});

Still appears in some CDN builds of dual-package libraries.

Comparison

Pattern Load Privacy Today
Global + IIFE script tags Closure Tiny embeds
CommonJS sync require file scope Node, some bundles
AMD async define factory scope Legacy
UMD hybrid factory CDN dual builds
ESM static + dynamic import module scope Default

What interviewers care about

  • Why globals are bad (collision, testing, tree-shaking)
  • How a closure gives “private” fields without # or ESM
  • That CJS is live-binding-free for the export object properties you mutate vs ESM live bindings
  • Circular deps behave differently in CJS vs ESM
// CJS: partial exports during cycle are common footgun
// ESM: live bindings; TDZ if you read before init

Interview answer (out loud)

“Before ESM, we used IIFEs and the revealing module pattern for private state, CommonJS for Node sync modules, and AMD/UMD for browser loaders. They’re all conventions built on functions and objects. ES modules give real isolation, static analysis, and import/export syntax — use those for new code, but recognize IIFE/CJS in legacy codebases.”

Further reading

Related guides