The Module Pattern and the Global Scope Problem

Why every variable in an old-school multi-script-tag JavaScript app used to leak onto the window, and how the module pattern, now built into the language, draws a real line between private and public.

September 6, 20266 min read1 / 2

This is a short detour from the Gang-of-Four sequence: two patterns JavaScript already builds into the language itself, starting with the oldest one. The module pattern solves a problem so old it's easy to forget it was ever a problem at all: every variable you wrote in the browser used to be visible to every other script on the page, whether you wanted that or not.

One Page, One Shared Scope

Picture an inventory system for an online store, split across three script tags loaded in the same HTML page: one that tracks stock, one that sells a unit, and one, written by a completely different team, that just wants to log some analytics.

HTML
<script src="stock.js"></script> <script src="sell.js"></script> <script src="analytics.js"></script>
JavaScript
// stock.js var warehouseCount = 500;
JavaScript
// sell.js warehouseCount -= 20;
JavaScript
// analytics.js console.log(warehouseCount); // 480

analytics.js never declared warehouseCount. It never imported anything. It just read a variable that some other file happened to create, and got the exact right value back, 480, after sell.js already changed it.

That's not a coincidence. Every one of those scripts runs in the exact same global scope, so var warehouseCount = 500; doesn't just create a variable, it creates a property directly on window. Any script on the page can read window.warehouseCount, write over it, or delete it, whether it has anything to do with your inventory system or not.

JavaScript
console.log(window.warehouseCount); // 480, same value, same variable window.warehouseCount = 'oops'; console.log(warehouseCount); // 'oops', every script just saw this change too

One shared variable name is now a shared piece of state between files that have never even heard of each other. Two large enough scripts are bound to reuse a name eventually, string, count, data, and whichever one runs last silently overwrites the other.

This isn't a hypothetical bug. Before real modules existed, developers wrapped code in a function that ran itself immediately, just to get a scope nothing else on the page could reach into.

JavaScript
(function () { var warehouseCount = 500; // lives only inside this function now window.reserveUnits = function (amount) { warehouseCount -= amount; return warehouseCount; }; })(); console.log(window.warehouseCount); // undefined, it's private now console.log(reserveUnits(20)); // 480, still works, through the one function exposed on purpose

The module pattern existed as this exact workaround, years before JavaScript gave you a real keyword for it. warehouseCount stays sealed inside the function. Only reserveUnits, the one thing deliberately hung on window, is reachable from outside.

Export Makes the Line Explicit

A module flips the default. Instead of everything being global unless you're careful, nothing leaves the file unless you say so with export.

JavaScript
// stock.js let warehouseCount = 500; export function reserveUnits(amount) { warehouseCount -= amount; return warehouseCount; } export function checkStock() { return warehouseCount; }
JavaScript
// index.js import { reserveUnits, checkStock } from './stock.js'; reserveUnits(20); console.log(checkStock()); // 480

warehouseCount was never exported, so index.js has no way to reach it directly.

JavaScript
// index.js, second attempt import { warehouseCount } from './stock.js';
Plain text
SyntaxError: The requested module './stock.js' does not provide an export named 'warehouseCount'

That's the real error, word for word, from Node. A browser's console shows the same kind of failure, the exact wording varies slightly by browser, but the result never changes: the import fails before any of your code even runs, not a warning, not undefined. The only way to change warehouseCount from outside is through the functions the module chose to expose, reserveUnits and checkStock. That's the entire pattern: a private scope by default, with a small, deliberate list of names allowed through.

Turning It On, in Two Places

In a browser, modules aren't automatic. Add type="module" to the script tag, and that file, and only that file, stops leaking into the global scope.

HTML
<script type="module" src="stock.js"></script> <script type="module" src="index.js"></script>

Without type="module", you're back to the old behavior: everything global, nothing private.

Node.js needs the same opt-in, and gives you two ways to do it. Either name the file with an .mjs extension, or add "type": "module" to package.json so every .js file in the project is treated as a module.

JSON
{ "name": "inventory-app", "type": "module" }

With that in place, import and export work exactly like the example above, including that same SyntaxError the moment anything tries to import a name the module never exported.

Native Modules Still Aren't a Bundler

The first time this clicked for me, I assumed type="module" meant I didn't need a bundler like Webpack or esbuild anymore. That's only half true.

type="module" teaches the browser to understand import and export between your own files, ./stock.js, ./index.js, anything reachable by a relative path. It does not teach the browser what to do with this:

JavaScript
import { formatDistanceToNow } from 'date-fns';

date-fns isn't a URL or a file path. It's a package name that only means something once something else resolves it, walks into node_modules, and finds the actual file to fetch. The browser has no idea node_modules exists. A bundler's real job isn't compressing your code, it's resolving every one of those bare package names into an actual file, then handing the browser one graph of real, fetchable paths.

That's why native modules and bundlers aren't competing solutions. One gives the language a private-by-default scope. The other makes sure every import, yours and every package you installed, actually resolves to something the browser can fetch.

Here's the whole arc of this post, in four steps:

Global scope
Every sibling script shares one `window`. A `var` or a function declaration in one file is readable and overwritable by every other.
The IIFE workaround
Before real modules existed, wrapping code in a function that runs itself was the only way to fake a private scope.
ES modules
Private is now the default. Nothing leaves a file unless it's named after `export`, enforced by the language itself.
A bundler
Resolves what `type="module"` alone can't: a bare package name like `date-fns`, into a real, fetchable file.
ℹ️ Run this yourself: the inventory example above, plus the starting challenge, are in the code-practice repo. Clone it, npm install, and try splitting starter/index.js into two modules before checking final/.

What You're Actually Trading

The module pattern costs you almost nothing and buys you two things at once. Privacy: any variable you don't export simply doesn't exist outside its file, no naming convention or leading-underscore convention required to fake it. Reuse: a module you write once can be imported into as many files as need it, instead of copy-pasted or loaded via yet another global script tag.

That second benefit is the one that matters more as an app grows. A formatCurrency function used on a product page, a cart page, and an invoice page only has to exist in one module, imported three times, instead of three near-identical copies drifting apart the moment someone fixes a bug in only one of them.

The next post in this detour covers a different kind of waste: not a leaked variable, but a method that gets rebuilt from scratch every time you create a new object, when every copy is identical. That's JavaScript's own prototype chain.

Reference