DEV Community

Cover image for How Modern JavaScript Dependency Management and Module Systems Actually Work
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

How Modern JavaScript Dependency Management and Module Systems Actually Work

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

JavaScript dependency management today is a different world from where we started. I remember the days when a simple require() statement felt like magic. You dropped a library into a node_modules folder, and it just worked. Today, that simple act is supported by a complex, intelligent system working behind the scenes. We're not just loading files anymore. We're orchestrating an entire ecosystem of code, formats, and dependencies across different environments, and doing it with remarkable efficiency.

This shift began with the fundamental split in the JavaScript language itself: CommonJS and ES modules. CommonJS, with its require() and module.exports, was the backbone of Node.js for years. It's synchronous and worked perfectly for server-side code. Then came ES modules, the official standard for the language, with import and export. They are asynchronous and static, designed for the browser's need to optimize and load efficiently. For a long time, these two systems didn't talk to each other well. Trying to import a CommonJS file or require() an ES module was a sure path to confusing errors.

Modern build tools—like Webpack, Vite, Rollup, and esbuild—have become translators and diplomats. They don't force you to choose one world. Instead, they bridge the gap seamlessly. They look at your code, figure out what you're trying to do, and transform it appropriately for its final destination. This interoperability is no longer a bonus feature; it's a basic expectation. You can have a modern React component written as an ES module that depends on an old, reliable utility library written in CommonJS, and your bundler handles the conversation between them without you ever knowing it happened.

Let's look at what this bridge looks like in practice. You might have a project that mixes both formats.

// In a modern .js or .mjs file (ES Module)
import { createRequire } from 'node:module';
import legacyPackage from './old-legacy-package.cjs'; // Importing CommonJS
import { shinyNewFunction } from './modern-module.mjs';

// Using createRequire to use require() within an ES module
const require = createRequire(import.meta.url);
const anotherOldThing = require('./another-old-thing.js');

// Meanwhile, a CommonJS file (.cjs) can *conditionally* load ES modules
async function loadModernCode() {
  // Dynamic import() works in CommonJS and is the key to interoperability
  const modernModule = await import('./modern-module.mjs');
  return modernModule.default;
}
Enter fullscreen mode Exit fullscreen mode

The build tool's configuration is where we teach it how to manage this relationship. Here's a simplified Webpack setup that prepares code for environments that support ES modules natively.

// webpack.config.js
module.exports = {
  // Enable experimental ES module output
  experiments: {
    outputModule: true,
  },
  module: {
    rules: [
      {
        test: /\.m?js$/, // Match both .js and .mjs files
        resolve: {
          fullySpecified: false, // Don't force file extensions
        },
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', {
                modules: false, // Tell Babel not to transform modules
                targets: {
                  esmodules: true // Target browsers supporting ES modules
                }
              }]
            ]
          }
        }
      }
    ]
  },
  output: {
    module: true, // Output as an ES module
    chunkFormat: 'module',
    environment: {
      module: true,
      dynamicImport: true // Support dynamic import()
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

And here’s a Rollup configuration that does something powerful: it builds your library twice, once for each system.

// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';

export default {
  input: 'src/main-entry.js',
  output: [
    {
      file: 'dist/my-library.esm.js',
      format: 'es', // ES Module format
      sourcemap: true
    },
    {
      file: 'dist/my-library.cjs.js',
      format: 'cjs', // CommonJS format
      sourcemap: true,
      exports: 'auto' // Automatically determine export style
    }
  ],
  plugins: [
    resolve({
      // Try these conditions when resolving an import
      exportConditions: ['node', 'import', 'require'],
      preferBuiltins: true
    }),
    commonjs({
      // Smart handling of CommonJS default exports
      requireReturnsDefault: 'auto',
    })
  ]
};
Enter fullscreen mode Exit fullscreen mode

This dual-output strategy is common for libraries. Your package.json then points to the right file.

{
  "name": "my-library",
  "main": "dist/my-library.cjs.js",
  "module": "dist/my-library.esm.js",
  "exports": {
    "import": "./dist/my-library.esm.js",
    "require": "./dist/my-library.cjs.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Once we can freely use different module types, the next challenge is optimization. Loading a single, massive JavaScript file when a user visits your site is a relic of the past. Modern users expect speed. This is where dynamic imports and code splitting come in. The concept is simple: only load the code the user needs, right when they need it.

Think about a large web application. You don't need the code for the "Admin Dashboard" when a regular customer is viewing the product page. Dynamic imports, using the import() function, let us split our application into logical pieces, or "chunks." Build tools analyze these import() calls and create separate files. Here are a few patterns I use regularly.

// 1. Route-based Splitting (classic for React, Vue, etc.)
import React, { Suspense, lazy } from 'react';

// These components are only loaded when their route is active
const HomePage = lazy(() => import('./pages/HomePage'));
const ProductPage = lazy(() => import('./pages/ProductPage'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));

function App() {
  return (
    <Router>
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/product/:id" element={<ProductPage />} />
          <Route path="/admin" element={<AdminDashboard />} />
        </Routes>
      </Suspense>
    </Router>
  );
}

// 2. Conditional Feature Loading
async function loadUserFeature(featureName) {
  switch (featureName) {
    case 'advancedCharting':
      // Only load the heavy chart library if the user needs it
      return await import('./features/advanced-charts');
    case 'documentEditor':
      return await import('./features/document-editor');
    default:
      return await import('./features/basic-view');
  }
}

// 3. Using Webpack's "Magic Comments" for fine control
const HeavyComponent = lazy(() => import(
  /* webpackChunkName: "heavy-widget" */
  /* webpackPrefetch: true */ // Hint to load during idle time
  /* webpackPreload: true */  // Hint to load with parent chunk
  './HeavyComponent'
));
Enter fullscreen mode Exit fullscreen mode

The bundler's configuration defines the strategy for how to split these chunks. This Webpack setup creates intelligent bundles.

// webpack.config.js - optimization section
module.exports = {
  // ... other config
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 20000, // Only split files above 20KB
      cacheGroups: {
        // Group all node_modules packages into a 'vendors' chunk
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
        },
        // Create separate chunks for large, specific libraries
        reactVendor: {
          test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
          name: 'react-vendor',
          chunks: 'all',
          priority: 20, // Higher priority
        },
      }
    },
    // Extract Webpack's runtime logic into its own small file
    runtimeChunk: {
      name: 'runtime',
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Vite, a newer build tool, makes this even more straightforward. It uses Rollup under the hood and has sensible defaults, but you can also provide custom logic.

// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        // Manual control over chunk splitting
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // Split react and related deps
            if (id.includes('react')) {
              return 'vendor-react';
            }
            // Split utility libraries
            if (id.includes('lodash') || id.includes('date-fns')) {
              return 'vendor-utils';
            }
            // Everything else from node_modules
            return 'vendor';
          }
          // Split your own code by directory structure
          if (id.includes('/src/components/')) {
            return 'components';
          }
        }
      }
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

The most revolutionary idea in dependency management in recent years, in my opinion, is Module Federation. It tackles a specific, growing problem: the micro-frontend architecture. When you have several independent applications (a main storefront, a user dashboard, an admin panel) that need to run together on the same page, how do you share dependencies like React? You don't want to load React three times.

Module Federation, a feature of Webpack 5, solves this by allowing a JavaScript application to dynamically load code from another application at runtime. More importantly, it allows them to share libraries. If App A loads React, App B can use that same instance instead of loading its own. This changes the game for large-scale applications.

Let's see a basic federation setup. We have two apps: host (the main app) and remote (a separate app exposing components).

// Configuration in the REMOTE app (exposing components)
// remote-app/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'remote_app', // Unique global name
      filename: 'remoteEntry.js', // The generated bootstrap file
      exposes: {
        // Expose specific modules
        './ModernButton': './src/components/ModernButton.jsx',
        './Header': './src/components/Header.jsx',
      },
      shared: {
        // Libraries to share with the host
        react: {
          singleton: true, // Only one instance
          requiredVersion: '^18.2.0', // Version requirement
        },
        'react-dom': {
          singleton: true,
          requiredVersion: '^18.2.0',
        },
      },
    }),
  ],
};

// Configuration in the HOST app (consuming the remote)
// host-app/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host_app',
      remotes: {
        // Define the remote. Format: name@URL_to_remoteEntry.js
        remote_app: 'remote_app@http://localhost:3002/remoteEntry.js',
      },
      shared: {
        // Specify shared dependencies. Will use remote's if available.
        react: {
          singleton: true,
          requiredVersion: '^18.2.0',
        },
        'react-dom': {
          singleton: true,
          requiredVersion: '^18.2.0',
        },
      },
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

In the host application's React code, you can now load the remote component as if it were local.

// In host-app/src/App.jsx
import React, { Suspense } from 'react';

// Dynamically import the federated module
const RemoteButton = React.lazy(() => import('remote_app/ModernButton'));

function App() {
  return (
    <div>
      <h1>Main Host Application</h1>
      <Suspense fallback={<div>Loading Button from Remote...</div>}>
        {/* This component is loaded from a different app! */}
        <RemoteButton onClick={() => alert('From Remote!')}>
          Click Me
        </RemoteButton>
      </Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This architecture is powerful. Teams can develop and deploy micro-frontends independently, while the framework ensures critical dependencies aren't duplicated. It’s dependency management at an application ecosystem level.

All these techniques help with download size, but "tree shaking" is about removing code you never even send. The term comes from the idea of shaking a tree so the dead leaves (unused code) fall out. Modern bundlers perform static analysis on your code. They trace through every import statement to see what is actually used. If you import a library of 100 utility functions but only use debounce and throttle, the other 98 functions are removed from your final bundle.

For this to work effectively, libraries need to cooperate by marking files that have "side effects." A side effect is code that runs simply by being imported, like a polyfill that modifies the global window object. You can't safely remove those files.

// A library's package.json helping bundlers tree-shake effectively
{
  "name": "my-optimized-utility-lib",
  "sideEffects": [
    "**/*.css",      // CSS files have side effects (apply styles)
    "polyfills/*.js" // Polyfill files modify globals
  ],
  "exports": {
    ".": {
      "import": "./dist/index.esm.js", // Tree-shakable ES module entry
      "require": "./dist/index.cjs.js"
    },
    "./lightweight": "./dist/lightweight-entry.js" // Optional entry point
  },
  "module": "./dist/index.esm.js", // Legacy field for ES module
  "main": "./dist/index.cjs.js"    // Legacy field for CommonJS
}
Enter fullscreen mode Exit fullscreen mode

As a developer, you can configure your bundler to be aggressive in its search for dead code.

// Webpack production config for tree shaking
module.exports = {
  mode: 'production',
  optimization: {
    usedExports: true, // Identify used exports
    sideEffects: true, // Respect package.json sideEffects flag
    concatenateModules: true, // Join modules for better minification
    minimize: true,
  },
};

// Rollup has excellent tree shaking built-in
// rollup.config.js
export default {
  input: 'src/index.js',
  output: { format: 'es' },
  treeshake: {
    propertyReadSideEffects: false,
    moduleSideEffects: false,
    preset: 'smallest', // Most aggressive safe setting
  },
  plugins: [terser()] // Minify after shaking
};
Enter fullscreen mode Exit fullscreen mode

Finally, we come to a development that could change the role of build tools themselves: Import Maps. This is a web standard that allows the browser to control module resolution natively, without a build step for mapping. You define a JSON object that says, "When you see an import for react, actually fetch it from this CDN URL."

This brings a PHP-like or JSPM-like simplicity to front-end development. You can use bare imports (import React from 'react') directly in the browser.

<!DOCTYPE html>
<script type="importmap">
{
  "imports": {
    "react": "https://esm.sh/react@18.2.0",
    "react-dom": "https://esm.sh/react-dom@18.2.0",
    "react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
    "my-components/": "/assets/components/"
  },
  "scopes": {
    "/admin/": {
      "special-admin-lib": "https://cdn.example.com/admin-lib/v1.js"
    }
  }
}
</script>

<script type="module">
  // The browser resolves 'react' using the importmap above
  import React, { useState } from 'react';
  import { createRoot } from 'react-dom/client';
  import Header from 'my-components/Header.js'; // Maps to /assets/components/Header.js

  // Your app code here...
</script>
Enter fullscreen mode Exit fullscreen mode

The beauty is in dynamic updates. You can write code to change the import map at runtime, redirecting dependencies on the fly for A/B testing, canary releases, or failover.

class DependencyManager {
  constructor() {
    this.importMap = { imports: {} };
  }

  async applyMap() {
    const script = document.createElement('script');
    script.type = 'importmap';
    script.textContent = JSON.stringify(this.importMap);
    // Remove old map, add new one
    document.head.querySelector('script[type="importmap"]')?.remove();
    document.head.appendChild(script);
  }

  addDependency(name, url) {
    this.importMap.imports[name] = url;
    return this.applyMap();
  }

  async loadWithFallback(moduleName, primaryUrl, fallbackUrl) {
    try {
      // Try the primary CDN
      await this.addDependency(moduleName, primaryUrl);
      return await import(moduleName);
    } catch (err) {
      console.warn(`Primary CDN failed, trying fallback for ${moduleName}`);
      // If it fails, update the map to the fallback and try again
      await this.addDependency(moduleName, fallbackUrl);
      return await import(moduleName);
    }
  }
}

// Usage
const dm = new DependencyManager();
// Dynamically load a library, with a backup plan
const chartLib = await dm.loadWithFallback(
  'chart-engine',
  'https://fast.cdn.example/charts/v3.js',
  'https://reliable-backup.cdn/charts/v2.js'
);
Enter fullscreen mode Exit fullscreen mode

This is the frontier. It moves complexity from the build pipeline to the runtime, offering incredible flexibility. For now, import maps work best for prototypes, internal tools, or CDN-based development, as browser support is good but not universal for all features. Tools like Vite can use them during development for a lightning-fast experience, while still bundling for production.

The journey from a simple list of script tags to this interconnected, intelligent system of module resolution and dependency management reflects the maturation of web development. We've moved from merely concatenating files to building a detailed understanding of our code's relationships. We optimize not just for the machine, but for the developer's experience and the end user's speed. We manage dependencies not just within a single codebase, but across independent teams and applications.

The core ideas are about choice and efficiency. Choice in module formats, choice in loading strategies, choice in architecture. Efficiency in bundle size, in network usage, in runtime performance. The modern JavaScript toolchain provides the framework for these choices, handling the intricate details so we can focus on building what matters. It’s a quiet, constant evolution that makes the ambitious applications of today possible.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)