skip to content

Now that most JavaScript ships as ES modules, where does an immediately invoked function expression still earn its place in a codebase, and where has it become noise you should delete?

level: principalimportance: nice to knowfreq 26%

answer

  1. the pattern replaced two missing features
  2. module file is already a scope
  3. a block scopes without calling anything
  4. keep it where no module exists
  5. eager, single instance, no seam

basics

~20 s

Delete it wherever a module file or a block already provides the scope: wrapping a whole module body buys nothing. Keep it for code that cannot be a module — injected snippets, embeds, build-free pages — and for an async wrapper where await is otherwise illegal.

solid answer

~50 s

The IIFE existed to buy two things JavaScript had no other way to get: a private scope, and a place to put initialization. Both have first-class replacements now. A module file is already its own scope, so a wrapper around the whole body is dead weight; and a bare block with `let`/`const` scopes a temporary computation without the function-call ceremony. What survives are the cases with no module in reach — a snippet injected into a page you do not control, a widget delivered as one script tag, a bookmarklet, a console one-liner, admin pages served without a build step — plus `(async () => { ... })()` when you need `await` somewhere it is not otherwise allowed. The costs to weigh when you keep one: it runs eagerly at load whether or not anything uses the result, it produces a single instance with no seam to inject fakes into, and it adds a frame that every reader has to decode before reaching the actual logic.

code

javascript · 17 lines
javascript
// Noise: the module file is already its own scope.
// const api = (function () { const secret = load(); return { get: () => secret }; })();
const secret = load();
export const api = { get: () => secret };

// Noise: a block scopes temporaries without a function call.
let config;
{
  const raw = readRaw();
  config = normalize(raw);
}

// Still earns its place: await where a top-level await is not available.
(async () => {
  const data = await fetchSeed();
  start(data);
})().catch((err) => report(err));

go deeper

for a junior

Know that a module file already gives each file its own scope, so you do not need to wrap your code in a function to keep names out of the global namespace.

for a middle

Be able to name the cheaper replacement for each old use: module scope for privacy, a bare block for temporaries, and an async wrapper only where await is otherwise illegal.

for a senior

Argue the costs concretely — eager execution with side effects at load, a single instance with no injection seam, and the reading tax of an undecodable wrapper — and show what you would delete first.

for a principal

Own the standard and the migration: write the rule for when a wrapper is justified, and address the deeper question of which state deserves to be a process-wide singleton created at load at all.

## What the pattern was actually buying An IIFE is not a design pattern so much as a workaround for two absences in the language as it stood before 2015. The first absence was scope. Only functions created one, so if you wanted a name that did not become a global — or, later, that did not leak out of a `for` body — you had to make a function and call it. The second was a place for setup: code that must run once at load, produce a value, and then hide its scaffolding. Both have direct replacements now, and the honest way to evaluate any surviving IIFE is to ask which of those two jobs it is doing and whether something cheaper does that job. ## Where it is now noise **Around a whole module body.** A module file has its own scope by construction: nothing declared at its top level is visible to another file unless it is exported. Wrapping the body in an IIFE adds a function frame and an indirection for exactly zero encapsulation. This is the single most common leftover, usually a survivor of an automated conversion, and it should simply be unwrapped. **To scope a temporary computation.** A bare block does it: ```js let config; { const raw = readRawConfig(); const parsed = parse(raw); config = normalize(parsed); } // raw and parsed are gone; no function was called ``` The block form is cheaper to read — no `return`, no invocation punctuation, no new `this` or `arguments` — and it makes the intent ("these three names are temporary") more obvious than a call whose result you assign. **As a substitute for private members.** Inside a module, a plain `const` at file scope is already unreachable from outside. The module pattern's whole selling point is redundant there. ## Where it still earns its place **Code that cannot be a module.** Anything you inject into a page you do not own — a third-party embed, an analytics tag, a bookmarklet, a browser-extension content script, a snippet pasted into a console — shares its global scope with strangers. A wrapper is the only thing standing between your temporaries and someone else's. This is the pattern's remaining home, and there it is not legacy at all. **Build-free surfaces.** Internal tools, admin pages, static pages with a couple of inline scripts. Introducing a build step to gain module scope is often the wrong trade for fifty lines of code; the wrapper is cheaper than the toolchain. **An `await` that has nowhere to live.** `(async () => { ... })()` gives you a body where `await` parses, in a context that does not allow it. Attach a `.catch()` — nothing else holds the returned promise, so an error inside it goes unhandled by default. **A one-shot value with real setup.** When building a value takes several intermediate names you do not want lingering, the immediately-invoked form is still the tightest expression of "compute this once and keep only the result" — though a named function called once is often clearer to a reader, and gives the frame a name in stack traces. ## The costs, stated plainly An IIFE runs *eagerly*, at the moment its statement is evaluated. Whatever it does — reading storage, touching a global, opening a connection — happens on load whether or not anything ever consumes the result. If the work is expensive or has side effects, a lazily-called function is the better shape. It produces *one* instance, created before any caller can influence it. That means no dependency injection and no per-test isolation: whatever it captured at load is what every test sees. The tell is a `resetForTests()` appearing on the public surface. Exporting the factory and letting each caller build its own instance solves this and costs nothing. It adds a frame a reader must decode. Everyone who opens the file has to determine whether the wrapper is doing something meaningful or is a leftover — a small tax, paid on every read, forever. ## How to decide, and how to enforce The rule I would write down for a team is: an IIFE is justified only when there is no module boundary available, or when it is an async wrapper for an `await` that has nowhere else to go. Everything else — module-body wrappers, scoping blocks, singletons that would be better as factories — gets unwrapped on sight. Migration is cheap because unwrapping is a mechanical edit with no behavioural change, provided the body did not rely on the wrapper's own `this` or on redeclaring a name that also exists outside. The more interesting decision underneath is not about the IIFE at all: it is whether a given piece of state should be a process-wide singleton created at load. The IIFE is just the syntax that makes that choice invisible. Making the choice explicit — factory by default, singleton only where the resource genuinely is one — is what actually improves the codebase.

  • Is unwrapping an IIFE around a module body always behaviour-preserving?
    Almost, but check two things. The wrapper had its own `this` and `arguments`, so a body relying on either changes meaning. And names that only did not collide because the wrapper hid them can now clash with module-level or imported names. Both are easy to spot, and neither is common in code that was already just a scope wrapper.
  • Why insist on a `.catch()` on an async IIFE?
    Because nothing else holds the promise it returns. Any rejection inside becomes an unhandled rejection, which is reported inconsistently and easy to miss in logs. Attaching a handler at the call site makes the failure path explicit and gives you one place to decide whether to log, retry, or exit.
  • How would you steer a team away from load-time singletons in general?
    Make the factory the default shape and require a written reason for a singleton — usually that the underlying resource genuinely is one, such as a connection pool. Then wire instances at a single composition point rather than at import time, so tests can build their own and nothing takes effect merely because a file was loaded.

saying these in an interview costs you the question

  • Says IIFEs are always obsolete in modern code
  • Keeps a wrapper around a module body for encapsulation
  • Uses an IIFE where a plain block would scope the names
  • Ignores that the wrapper runs eagerly with side effects
  • Treats a load-time singleton as free because it is only one object

context