Deploy Native JavaScript Modules in Production: Best Practices & Performance Gains
This article explains how modern browsers now support native ES2015 modules, why earlier performance concerns were based on outdated tests, and provides detailed guidance on using Rollup, code‑splitting, dynamic imports, modulepreload, and polyfills to achieve faster, smaller, and future‑proof web applications.
Common Misunderstandings About Modules
Many developers believe that modules (the ES‑module syntax and loading mechanism) are only suitable for large‑scale production apps. They often cite an old study that claimed loading modules is slower than loading a single bundled script, recommending bundling unless the app has fewer than 100 modules with a shallow dependency tree (depth < 5). That study used unoptimized source files and did not compare optimized module bundles to optimized scripts, making its conclusions incomplete.
Today, bundlers have advanced enough to output ES2015 modules (including static and dynamic imports) that outperform non‑module scripts. The site referenced in the original article has been using native modules in production for several months.
Optimal Bundling Strategy
Bundling always involves trade‑offs between load speed, execution speed, and cacheability. Deploying ES2015 modules directly lets you change small parts of the code without invalidating the entire bundle cache, though it may increase the time needed for a new user to download all modules.
The challenge is to find the right granularity of code splitting that balances loading performance with long‑term caching. Dynamic import‑based splitting is often too coarse for sites with many returning users. Empirical data suggests that loading fewer than 100 modules shows no noticeable performance difference, and loading fewer than 50 files over HTTP/2 also shows little impact.
For best results, split code as finely as possible—down to the level of individual npm packages—until further splitting no longer improves load time.
Package‑Level Code Splitting
Rollup now supports two features that make high‑performance module deployment easy:
Automatic code splitting on dynamic
import()(added in v1.0.0).
Programmable manual splitting via the
manualChunksoption (added in v1.11.0).
Example configuration that groups every module inside
node_modulesinto a file named after its package:
<code>export default {
input: {
main: 'src/main.mjs'
},
output: {
dir: 'build',
format: 'esm',
entryFileNames: '[name].[hash].mjs'
},
manualChunks(id) {
if (id.includes('node_modules')) {
const dirs = id.split(path.sep);
return dirs[dirs.lastIndexOf('node_modules') + 1];
}
}
};
</code>The
manualChunksfunction receives a module path and returns a name; modules without a returned name go into the default chunk.
Using this configuration, imports from a package such as
lodash-es(e.g.,
cloneDeep(),
debounce(),
find()) are bundled together into a file like
npm.lodash-es.XXXX.mjs, where
XXXXis a hash of the package.
What If You Have Hundreds of npm Dependencies?
Package‑level splitting remains the optimal approach, but if an app imports many different npm packages, the browser may struggle to load all modules efficiently. In that case, group related packages (e.g.,
reactand
react-dom) into a shared chunk because they are typically needed together.
Dynamic Import
Native dynamic
import()enables lazy loading but requires a fallback for browsers that support modules but not dynamic imports (Edge 16‑18, Firefox 60‑66, Safari 11, Chrome 61‑63). A tiny (~400 bytes) polyfill can provide this functionality.
To use the polyfill, import it and initialize before any dynamic imports:
<code>import dynamicImportPolyfill from 'dynamic-import-polyfill';
dynamicImportPolyfill.initialize({ modulePath: '/modules/' });
</code>Rollup can rename the generated dynamic import function via the
output.dynamicImportFunctionoption, avoiding conflicts with the reserved
importkeyword.
Efficient Loading of JavaScript Modules
When code‑splitting, pre‑load all modules that will be needed immediately using
modulepreloadinstead of the traditional
preload.
modulepreloaddownloads, parses, and compiles modules off the main thread, resulting in faster execution and less main‑thread blocking.
Example preload links for a main module and three npm package chunks:
<code><link rel="modulepreload" href="/modules/main.XXXX.mjs">
<link rel="modulepreload" href="/modules/npm.pkg-one.XXXX.mjs">
<link rel="modulepreload" href="/modules/npm.pkg-two.XXXX.mjs">
<link rel="modulepreload" href="/modules/npm.pkg-three.XXXX.mjs">
</code>Rollup’s
generateBundlehook can automatically build a map of entry chunks to their full dependency lists, which can then be turned into a
modulepreloadlist.
Why Deploy Native Modules?
Smaller Code Size
Modern browsers load native modules without any runtime loader or manifest code, eliminating the overhead of bundler runtimes such as Webpack’s.
Better Pre‑loading
modulepreloadloads and compiles modules off the main thread, leading to faster interaction and less blocking compared to classic
preloadof script files.
Future‑Proof
Many upcoming web platform features (standard library modules, HTML modules, CSS modules, JSON modules, import maps, shared workers, etc.) are built on top of ES modules. Deploying as native modules ensures compatibility with these innovations.
Supporting Older Browsers
Over 83 % of browsers worldwide natively support JavaScript modules (including dynamic imports). For browsers that support modules but not dynamic imports, use the small polyfill mentioned earlier. For browsers that do not support modules at all, fall back to the classic
module/nomodulepattern.
A Real‑World Example
A demo application (hosted on Glitch) implements all the techniques described: Babel/JSX transformation, CommonJS dependencies (React, React‑DOM), CSS handling, asset hashing, code splitting, dynamic imports with polyfill, and module/nomodule fallback. The source is on GitHub, allowing you to fork and build it yourself.
Summary
Deploying native JavaScript modules in production is now practical and offers performance benefits. Follow these steps:
Use a bundler that outputs ES2015 modules.
Split code aggressively, ideally down to individual npm packages.
Pre‑load all static dependencies with
modulepreload.
Include a tiny polyfill to support browsers lacking dynamic
import().
Use the
<script nomodule>fallback for browsers that do not support modules at all.
By adopting these practices, you can achieve smaller bundles, faster loading, and future‑ready web applications.
WecTeam
WecTeam (维C团) is the front‑end technology team of JD.com’s Jingxi business unit, focusing on front‑end engineering, web performance optimization, mini‑program and app development, serverless, multi‑platform reuse, and visual building.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.