Encapsulate state, expose a clean public API, avoid global leaks, and make legacy or script-tag projects maintainable without a build step.

Introduction: Why this pattern still matters
In a world of ES modules and bundlers, you might think the Revealing Module Pattern (RMP) is obsolete. It isn’t. Any time you:
- ship plain
<script>
files without a bundler, - integrate into CMSes, browser extensions, or legacy apps,
- want private state and a tiny, explicit API,
…the RMP gives you encapsulation via closures and a crystal-clear “what’s public” list, no framework required.
This guide explains when and why to use it, shows modern, copy-pasteable code, and compares it to ES modules/classes so you can pick the right tool.
What is the Revealing Module Pattern?
The core idea:
- Wrap private variables and functions in a closure (usually an IIFE or factory).
- Return an object that exposes only the functions you want to be public.
- “Reveal” the mapping from private function → public method at the bottom.
// IIFE singleton
const Storage = (function () {
const PREFIX = "__app__"; // private
const key = (k) => `${PREFIX}:${k}`; // private
function set(k, v) { // private impl
localStorage.setItem(key(k), JSON.stringify(v));
}
function get(k, fallback = null) {
const raw = localStorage.getItem(key(k));
return raw ? JSON.parse(raw) : fallback;
}
function remove(k) {
localStorage.removeItem(key(k));
}
// reveal public API (one glance = full surface area)
return { set, get, remove };
})();
- Everything inside is private except what you return.
- The “revealing” part is the final
return { ... }
.
When to use it (rule of thumb)
Use the Revealing Module Pattern when you need at least two of the following:
- Encapsulation without a build step (plain scripts, legacy stacks).
- Singleton-like services (logger, config, feature flags, event bus).
- A minimal public API that won’t leak internals.
- Testable seams by injecting dependencies (e.g., custom
fetch
). - Name-spacing to avoid polluting
window
.
Don’t reach for it when:
- You already have ES modules (just keep private items unexported).
- You need many instances with shared prototypes (consider classes or factory functions returning objects).
- You need subclassing/inheritance (classes or composition).
Why it’s useful (practical benefits)
- Privacy by default: state and helpers are invisible outside the closure.
- Readability: a single
return { … }
makes your public API obvious. - Safer refactors: rename internals freely; callers only depend on the revealed API.
- No globals: expose exactly one name (or none, when assigning to module systems).
- Zero tooling: works in every browser/Node environment.
Pattern variants (choose your flavor)
1) Singleton IIFE (classic)
Good for one global service.
const Bus = (function () {
const events = new Map();
function on(evt, fn){ (events.get(evt) ?? events.set(evt, []).get(evt)).push(fn); }
function off(evt, fn){ events.set(evt, (events.get(evt) || []).filter(f => f !== fn)); }
function emit(evt, data){ (events.get(evt) || []).forEach(fn => fn(data)); }
return { on, off, emit };
})();
2) Factory (multiple instances)
Same idea, but returns a new module each time.
function createRateLimiter({ max = 5, windowMs = 1000 } = {}) {
let tokens = max;
let last = Date.now();
function tryRemoveToken() {
const now = Date.now();
const refill = Math.floor((now - last) / windowMs) * max;
if (refill > 0) { tokens = Math.min(max, tokens + refill); last = now; }
if (tokens > 0) { tokens--; return true; }
return false;
}
return { tryRemoveToken }; // revealed API
}
3) Dependency-injected
Keep it testable by not hard-coding globals.
function createApi({ baseURL, fetchImpl = fetch }) {
async function get(path){ const r = await fetchImpl(baseURL + path); if (!r.ok) throw new Error(r.status); return r.json(); }
return { get };
}
5 real-world modules (copy–paste friendly)
A) Namespaced Local Storage (with JSON safety)
const KV = (function () {
const PREFIX = "app";
const safe = (fn, fallback) => { try { return fn(); } catch { return fallback; } };
function key(k){ return `${PREFIX}:${k}`; }
function set(k, v){ safe(() => localStorage.setItem(key(k), JSON.stringify(v))); }
function get(k, fallback=null){ return safe(() => JSON.parse(localStorage.getItem(key(k))), fallback); }
function remove(k){ safe(() => localStorage.removeItem(key(k))); }
function clear(){ Object.keys(localStorage).filter(k => k.startsWith(`${PREFIX}:`)).forEach(localStorage.removeItem.bind(localStorage)); }
return { set, get, remove, clear };
})();
B) Feature Flags (async + cache)
function createFeatureFlags({ fetchImpl = fetch, url, cacheTtlMs = 60_000 }) {
let cache = null, expires = 0;
async function load() {
const now = Date.now();
if (cache && now < expires) return cache;
const res = await fetchImpl(url); cache = await res.json(); expires = now + cacheTtlMs; return cache;
}
async function enabled(name) { const flags = await load(); return !!flags[name]; }
return { enabled, _load: load }; // reveal minimal API (keep _load for tests if you want)
}
C) Modal Controller (DOM, teardown included)
function createModal(selector) {
const el = document.querySelector(selector);
if (!el) throw new Error("Modal not found");
let open = false;
function show(){ el.style.display = "block"; open = true; }
function hide(){ el.style.display = "none"; open = false; }
function isOpen(){ return open; }
function destroy(){ hide(); /* remove listeners if you add any */ }
return { show, hide, isOpen, destroy };
}
D) Request Queue (concurrency control)
function createQueue({ concurrency = 4 } = {}) {
const q = []; let active = 0;
function run() {
if (active >= concurrency || q.length === 0) return;
active++;
const { task, resolve, reject } = q.shift();
Promise.resolve().then(task).then(resolve, reject).finally(() => { active--; run(); });
run();
}
function push(task){ return new Promise((resolve, reject) => { q.push({ task, resolve, reject }); run(); }); }
return { push };
}
E) Logger with sinks and levels
function createLogger({ level = "info", sink = console } = {}) {
const levels = ["debug","info","warn","error"];
const idx = levels.indexOf(level);
const ok = (l) => levels.indexOf(l) >= idx;
function debug(...a){ ok("debug") && sink.debug("[debug]", ...a); }
function info (...a){ ok("info" ) && sink.info ("[info ]", ...a); }
function warn (...a){ ok("warn" ) && sink.warn ("[warn ]", ...a); }
function error(...a){ ok("error") && sink.error("[error]", ...a); }
return { debug, info, warn, error };
}
How it compares (and when to pick something else)
RMP vs Classic Module Pattern
Both use closures. The revealing version centralizes the public API in one, return
so readers instantly see what’s exported. That boosts readability and prevents accidental exposure.
RMP vs ES Modules
If you can use ES modules, do it simply export public items and keep the rest file-private:
// userRepo.mjs
const pool = new Map(); // private to the module file
function save(u){ pool.set(u.id, u); }
function get(id){ return pool.get(id); }
export { save, get }; // explicit public API
ESM gives you tree-shaking, tooling, and type inference. Use RMP when you can’t rely on a module loader or you deliberately want a single object namespace.
RMP vs Classes (#private fields)
Classes are great for many instances and inheritance. If you just need a single service with private state, RMP is lighter. If you need multiple instances with encapsulation plus ergonomics, consider classes with #private
fields or the factory variant of RMP.
Gotchas (and how to avoid them)
- Singleton by accident: The IIFE version creates exactly one instance. If you need many, use
createX()
factories. - Testing private state: You can’t (and shouldn’t) reach private internals. Test behavior. If you must, expose a guarded debug hook in dev builds only.
- Leaking listeners/timers: Modules that attach DOM listeners or timers should expose a
destroy()
method and use it in tests/storybooks. this
confusion: Methods in RMP typically close over private variables; avoid relying onthis
. If you do, bind explicitly or use arrow functions.- Hidden dependencies: Don’t reach for globals inside; inject
fetch
,document
,Date.now
, etc., via params to keep it testable and portable. - Over-revealing: Don’t dump every function in
return { … }
. If callers don’t need it, keep it private.
TypeScript (or JSDoc) typing of a revealed module
With TS:
// featureFlags.ts
export type FlagsApi = { enabled(name: string): Promise<boolean> };
export function createFeatureFlags(cfg: { url: string; fetchImpl?: typeof fetch }): FlagsApi {
// ...same as JS...
return { enabled };
}
With JSDoc in JS:
/**
* @typedef {Object} FlagsApi
* @property {(name: string) => Promise<boolean>} enabled
*/
/**
* @param {{ url: string, fetchImpl?: typeof fetch }} cfg
* @returns {FlagsApi}
*/
function createFeatureFlags(cfg) { /* ... */ }
Typing the returned API preserves the spirit of the pattern: internals stay private, callers get a contract.
Performance notes
Closures are cheap; the cost here is negligible versus network, DOM, or rendering. The biggest performance wins come from encapsulating policies (retry, cache, queue) inside the module so callers don’t re-implement them badly.
Migration playbook: from globals/switches to RMP
- Identify repeated constructors, scattered helpers, or files touching the same global.
- Wrap them in a closure; expose a tiny
return { … }
. - Inject dependencies (
fetch
,document
,Date
, sinks). - Replace call sites to use the revealed API.
- Add tests against the public API.
- Optionally upgrade later to ES modules or classes without breaking callers.
Decision checklist (print this)
- Do I need privacy without a build step? → RMP ✅
- Is this a singleton-style service (config, logger, flags, bus)? → RMP ✅
- Do I need multiple instances? → RMP factory or class
- Do I already use ESM/bundlers? → Prefer ES modules; RMP only if a single namespace object helps DX
- Will I test it? → Inject dependencies; expose
destroy()
if side effects exist
Conclusion
The Revealing Module Pattern is a small, sharp tool:
- It encapsulates the state with closures,
- Reveals a minimal API at one glance,
- Works anywhere from legacy script tags to modern stacks,
- And stays composable and testable when you inject dependencies and mind teardown.
Use it when you want clarity and privacy without ceremony. Prefer ES modules when you have the tooling. Mix in the factory variant when you need instances. And always reveal only what callers truly need.
Call to Action
Which service in your codebase could benefit from a tiny, well-encapsulated module, a logger, flags, or a queue? Tell me what you’re building, and I’ll sketch a tailored revealing module for it 👇
👉 Share this with a teammate shipping plain scripts.
🔖 Bookmark for your next refactor session.
Leave a Reply