Frontend Development 21 min read

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.

WecTeam
WecTeam
WecTeam
Deploy Native JavaScript Modules in Production: Best Practices & Performance Gains

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

manualChunks

option (added in v1.11.0).

Example configuration that groups every module inside

node_modules

into 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

manualChunks

function 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

XXXX

is 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.,

react

and

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.dynamicImportFunction

option, avoiding conflicts with the reserved

import

keyword.

Efficient Loading of JavaScript Modules

When code‑splitting, pre‑load all modules that will be needed immediately using

modulepreload

instead of the traditional

preload

.

modulepreload

downloads, 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>&lt;link rel="modulepreload" href="/modules/main.XXXX.mjs"&gt;
&lt;link rel="modulepreload" href="/modules/npm.pkg-one.XXXX.mjs"&gt;
&lt;link rel="modulepreload" href="/modules/npm.pkg-two.XXXX.mjs"&gt;
&lt;link rel="modulepreload" href="/modules/npm.pkg-three.XXXX.mjs"&gt;
</code>

Rollup’s

generateBundle

hook can automatically build a map of entry chunks to their full dependency lists, which can then be turned into a

modulepreload

list.

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

modulepreload

loads and compiles modules off the main thread, leading to faster interaction and less blocking compared to classic

preload

of 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/nomodule

pattern.

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.

Demo application screenshot
Demo application screenshot

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

&lt;script nomodule&gt;

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.

performanceJavaScriptWeb DevelopmentCode SplittingRollupESModules
WecTeam
Written by

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.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.