Modules are how modern JavaScript divides code into reusable pieces. Understanding the two main module systems — ES Modules (ESM) and CommonJS (CJS) — and how runtimes and tools load them will make debugging, packaging, and deployment much simpler.
What are the two module systems?
ES Modules use import and export syntax that the language and browsers standardize. Imports are static (declared at parse time), which helps tooling analyze dependencies and enables optimizations like tree-shaking. CommonJS, the original Node module format, uses require and module.exports and evaluates module loading at runtime, which allows dynamic patterns but limits static analysis.
How browsers load modules
Browsers load ES Modules directly using <script type=”module”> or by fetching module files referenced by import statements. Module scripts are deferred automatically and support the dynamic import() function to load code at runtime. This native support reduces the need to bundle during development and enables faster iteration when combined with modern dev servers.
How Node handles modules
Node supports both CommonJS and ES Modules, but they are not identical. Node determines a file’s module type by extension (.mjs signals ESM) or by the package.json “type” field (“type”: “module” makes .js files be treated as ESM). That means a codebase can mix systems but will need explicit configuration and occasional interop code when consuming packages that use the other system.
Why tooling matters: dev servers and bundlers
Modern development tools lean on native ESM for a faster developer experience. Some dev servers serve source files over native ESM and only pre-bundle certain dependencies, enabling near-instant hot module replacement and cheaper rebuilds. Production builds typically still bundle and optimize for browsers, but using ESM-aware tools streamlines the dev→build transition.
Practical migration and compatibility tips
- Prefer ESM for new projects to align with the language standard and browser behavior; use import/export where possible.
- If you must interoperate with CJS modules, import them using default or named wrappers and be mindful that CJS can’t always directly import ESM without extra build steps.
- Use explicit package configuration so Node treats files as you intend; mixing without clarity causes subtle runtime errors.
- Avoid heavy runtime require() patterns when you want tree-shaking and smaller bundles — static imports enable those optimizations.
- Test both development and production builds: dev servers often behave differently than a bundled production bundle, especially around how dependencies are resolved and served.
Once you understand the distinctions — static vs runtime loading, how browsers natively handle ESM, and how Node determines module type — you can choose tooling and configuration that match your app’s needs. The outcome is clearer module boundaries, smaller production bundles, and a smoother developer workflow.

