Scope hoisting is the webpack optimization that merges modules with their dependencies into a single scope, reducing the overhead of function calls and improving runtime performance.

Let’s see it in action. Imagine you have a small project with two files:

src/math.js:

export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

src/index.js:

import { add } from './math.js';

console.log('2 + 3 =', add(2, 3));

Without scope hoisting (e.g., older webpack versions or specific configurations), webpack might bundle this like this:

// webpackBootstrap
(function(modules) { // webpackBootstrap
    // The module cache
    var installedModules = {};

    // The require function
    function __webpack_require__(moduleId) {
        // Check if module is in cache
        if(installedModules[moduleId]) {
            return installedModules[moduleId].exports;
        }
        // Create a new module (and put it into the cache)
        var module = installedModules[moduleId] = {
            i: moduleId,
            l: false,
            exports: {}
        };

        // Execute the module function
        modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

        // Flag the module as loaded
        module.l = true;

        // Return the exports of the module
        return module.exports;
    }

    // Load entry module and return exports
    return __webpack_require__(__webpack_require__.s = 0);
})([
/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
    /* harmony import */ var _math_js__WEBPACK_ மூல__ = __webpack_require__(1);


    console.log('2 + 3 =', Object(_math_js__WEBPACK_ மூல__["add"])(2, 3));
    /***/
}),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
    /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "add", function() { return add; });
    /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "subtract", function() { return subtract; });

    function add(a, b) {
      return a + b;
    }

    function subtract(a, b) {
      return a - b;
    }
    /***/
})
]);

Notice how src/index.js (module 0) has to call __webpack_require__(1) to get the add function from src/math.js (module 1). This __webpack_require__ call is a small piece of overhead for each module import.

With scope hoisting enabled (which is the default in modern webpack versions), webpack analyzes your module graph and realizes that math.js is only used by index.js. It can then "hoist" the code from math.js directly into the scope of index.js, eliminating the need for __webpack_require__ calls for these internal modules. The bundled output might look more like this:

(function(modules) { // webpackBootstrap
    // ... (webpackBootstrap remains similar) ...
})([
/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    // No import from './math.js' here!
    // The 'add' function is now defined directly within this scope.

    function add(a, b) {
      return a + b;
    }

    // ... other exports if any ...

    console.log('2 + 3 =', add(2, 3)); // 'add' is directly available
    /***/
})
// Module 1 might not even exist as a separate chunk if it's inlined
]);

The core problem scope hoisting solves is the overhead associated with webpack’s module wrapper. Every module, by default, is wrapped in an IIFE (Immediately Invoked Function Expression) like (function(module, exports, __webpack_require__) { ... }). This wrapper provides a private scope for each module and exposes module.exports to the rest of the application. When module A imports module B, webpack generates code in module A that calls __webpack_require__(<module_B_id>). This __webpack_require__ function then looks up module B in its cache, executes its wrapper if not already loaded, and returns its exports.

Scope hoisting intelligently flattens this structure. If module B is only used by module A and not by any other module, and if it’s not an entry point itself, webpack can inline the code from module B directly into the IIFE of module A. This means the add function from math.js is no longer a separate entity that needs to be looked up and imported; it’s just a function defined directly within the scope where it’s used.

This has several benefits:

  1. Reduced Function Call Overhead: Instead of __webpack_require__ calls and property lookups (like Object(_math_js__WEBPACK_ மூல__["add"])), you get direct function calls (add(...)).
  2. Smaller Bundle Size: Eliminating the module wrapper IIFEs and __webpack_require__ calls for hoisted modules saves bytes.
  3. Improved Minification: With all code in a single scope, minifiers can perform more aggressive optimizations, like variable renaming and dead code elimination, across formerly separate modules.
  4. Better Performance: Faster parsing and execution due to fewer function calls and a more contiguous code structure.

Scope hoisting works best with ES Modules (import/export) because they are statically analyzable. Webpack can determine at build time which modules depend on which others, and which modules are only used by a single other module. CommonJS modules (require/module.exports) are dynamically evaluated, making static analysis for hoisting more challenging.

The key levers you control are your module structure and your webpack configuration. Modern webpack (v4+) enables scope hoisting by default when using ES Modules and mode: 'production'. If you’re using older versions or have specific configurations that disable it, you might need to explicitly enable it. For instance, in webpack v3, you’d use optimization.concatenateModules: true. In webpack v4 and above, this is handled automatically by optimization.usedExports and optimization.sideEffects in production mode.

The one thing most people don’t realize is that scope hoisting isn’t just about merging code; it’s about creating a single, unified scope where variables and functions can be directly referenced. This allows the JavaScript engine to perform optimizations like inlining functions at a much deeper level than it could with separate, wrapped modules. When a function is hoisted and defined directly within the scope of its usage, it becomes a candidate for inlining by the V8 engine (or other JS engines), potentially replacing the function call entirely with the function’s body at runtime.

The next concept you’ll likely encounter when optimizing bundles is code splitting.

Want structured learning?

Take the full Webpack course →