Improve performance by turning on modern JavaScript dependencies and output.
Over 90% of browsers are capable of running modern JavaScript, but the prevalence of legacy JavaScript remains a large source of performance problems on the web today.
Modern JavaScript
Modern JavaScript is not characterized as code written in a specific ECMAScript specification version, but rather in syntax that is supported by all modern browsers. Modern web browsers like Chrome, Edge, Firefox, and Safari make up more than 90% of the browser market, and different browsers that rely on the same underlying rendering engines make up an additional 5%. This means that 95% of global web traffic comes from browsers that support the most widely used JavaScript language features from the past 10 years, including:
- Classes (ES2015)
- Arrow functions (ES2015)
- Generators (ES2015)
- Block scoping (ES2015)
- Destructuring (ES2015)
- Rest and spread parameters (ES2015)
- Object shorthand (ES2015)
- Async/await (ES2017)
Features in newer versions of the language specification generally have less consistent support across modern browsers. For example, many ES2020 and ES2021 features are only supported in 70% of the browser market—still the majority of browsers, but not enough that it's safe to rely on those features directly. This means that although "modern" JavaScript is a moving target, ES2017 has the widest range of browser compatibility while including most of the commonly used modern syntax features. In other words, ES2017 is the closest to modern syntax today.
Legacy JavaScript
Legacy JavaScript is code that specifically avoids using all the above language features. Most developers write their source code using modern syntax, but compile everything to legacy syntax for increased browser support. Compiling to legacy syntax does increase browser support, however the effect is often smaller than we realize. In many cases the support increases from around 95% to 98% while incurring a significant cost:
Legacy JavaScript is typically around 20% larger and slower than equivalent modern code. Tooling deficiencies and misconfiguration often widen this gap even further.
Installed libraries account for as much as 90% of typical production JavaScript code. Library code incurs an even higher legacy JavaScript overhead due to polyfill and helper duplication that could be avoided by publishing modern code.
Modern JavaScript on npm
Recently, Node.js has standardized an "exports"
field to define
entry points for a package:
{
"exports": "./index.js"
}
Modules referenced by the "exports"
field imply a Node version of at least
12.8, which supports ES2019. This means that any module referenced using the
"exports"
field can be written in modern JavaScript. Package consumers must
assume modules with an "exports"
field contain modern code and transpile if
necessary.
Modern-only
If you want to publish a package with modern code and leave it up to the
consumer to handle transpiling it when they use it as a dependency—use only the
"exports"
field.
{
"name": "foo",
"exports": "./modern.js"
}
Modern with legacy fallback
Use the "exports"
field along with "main"
in order to publish your package
using modern code but also include an ES5 + CommonJS fallback for legacy
browsers.
{
"name": "foo",
"exports": "./modern.js",
"main": "./legacy.cjs"
}
Modern with legacy fallback and ESM bundler optimizations
In addition to defining a fallback CommonJS entrypoint, the "module"
field can
be used to point to a similar legacy fallback bundle, but one that uses
JavaScript module syntax (import
and export
).
{
"name": "foo",
"exports": "./modern.js",
"main": "./legacy.cjs",
"module": "./module.js"
}
Many bundlers, such as webpack and Rollup, rely on this field to take advantage
of module features and enable
tree shaking.
This is still a legacy bundle that does not contain any modern code aside from
import
/export
syntax, so use this approach to ship modern code with a
legacy fallback that is still optimized for bundling.
Modern JavaScript in applications
Third-party dependencies make up the vast majority of typical production JavaScript code in web applications. While npm dependencies have historically been published as legacy ES5 syntax, this is no longer a safe assumption and risks dependency updates breaking browser support in your application.
With an increasing number of npm packages moving to modern JavaScript, it's important to ensure that the build tooling is set up to handle them. There's a good chance some of the npm packages you depend on are already using modern language features. There are a number of options available to use modern code from npm without breaking your application in older browsers, but the general idea is to have the build system transpile dependencies to the same syntax target as your source code.
webpack
As of webpack 5, it is now possible to configure what syntax webpack will use when generating code for bundles and modules. This doesn't transpile your code or dependencies, it only affects the "glue" code generated by webpack. To specify the browser support target, add a browserslist configuration to your project, or do it directly in your webpack configuration:
module.exports = {
target: ['web', 'es2017'],
};
It is also possible to configure webpack to generate optimized bundles that
omit unnecessary wrapper functions when targeting a modern ES Modules
environment. This also configures webpack to load code-split bundles using
<script type="module">
.
module.exports = {
target: ['web', 'es2017'],
output: {
module: true,
},
experiments: {
outputModule: true,
},
};
There are a number of webpack plugins available that make it possible to compile and ship modern JavaScript while still supporting legacy browsers, such as Optimize Plugin and BabelEsmPlugin.
Optimize Plugin
Optimize Plugin is a webpack plugin that transforms final bundled code from modern to legacy JavaScript instead of each individual source file. It's a self-contained setup that allows your webpack configuration to assume everything is modern JavaScript with no special branching for multiple outputs or syntaxes.
Since Optimize Plugin operates on bundles instead of individual modules, it processes your application's code and your dependencies equally. This makes it safe to use modern JavaScript dependencies from npm, because their code will be bundled and transpiled to the correct syntax. It can also be faster than traditional solutions involving two compilation steps, while still generating separate bundles for modern and legacy browsers. The two sets of bundles are designed to be loaded using the module/nomodule pattern.
// webpack.config.js
const OptimizePlugin = require('optimize-plugin');
module.exports = {
// ...
plugins: [new OptimizePlugin()],
};
Optimize Plugin
can be faster and more efficient than custom webpack
configurations, which typically bundle modern and legacy code separately. It
also handles running Babel for you, and minifies
bundles using Terser with separate optimal settings for
the modern and legacy outputs. Finally, polyfills needed by the generated
legacy bundles are extracted into a dedicated script so they are never
duplicated or unnecessarily loaded in newer browsers.
BabelEsmPlugin
BabelEsmPlugin is a webpack plugin that works along with @babel/preset-env to generate modern versions of existing bundles to ship less transpiled code to modern browsers. It is the most popular off-the-shelf solution for module/nomodule, used by Next.js and Preact CLI.
// webpack.config.js
const BabelEsmPlugin = require('babel-esm-plugin');
module.exports = {
//...
module: {
rules: [
// your existing babel-loader configuration:
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
},
},
},
],
},
plugins: [new BabelEsmPlugin()],
};
BabelEsmPlugin
supports a wide array of webpack configurations, because it
runs two largely separate builds of your application. Compiling twice can take a
little bit of extra time for large applications, however this technique allows
BabelEsmPlugin
to integrate seamlessly into existing webpack configurations
and makes it one of the most convenient options available.
Configure babel-loader to transpile node_modules
If you are using babel-loader
without one of the previous two plugins,
there's an important step required in order to consume modern JavaScript npm
modules. Defining two separate babel-loader
configurations makes it possible
to automatically compile modern language features found in node_modules
to
ES2017, while still transpiling your own first-party code with the Babel
plugins and presets defined in your project's configuration. This doesn't
generate modern and legacy bundles for a module/nomodule setup, but it does
make it possible to install and use npm packages that contain modern JavaScript
without breaking older browsers.
webpack-plugin-modern-npm
uses this technique to compile npm dependencies that have an "exports"
field
in their package.json
, since these may contain modern syntax:
// webpack.config.js
const ModernNpmPlugin = require('webpack-plugin-modern-npm');
module.exports = {
plugins: [
// auto-transpile modern stuff found in node_modules
new ModernNpmPlugin(),
],
};
Alternatively, you can implement the technique manually in your webpack
configuration by checking for an "exports"
field in the package.json
of
modules as they are resolved. Omitting caching for brevity, a custom
implementation might look like this:
// webpack.config.js
module.exports = {
module: {
rules: [
// Transpile for your own first-party code:
{
test: /\.js$/i,
loader: 'babel-loader',
exclude: /node_modules/,
},
// Transpile modern dependencies:
{
test: /\.js$/i,
include(file) {
let dir = file.match(/^.*[/\\]node_modules[/\\](@.*?[/\\])?.*?[/\\]/);
try {
return dir && !!require(dir[0] + 'package.json').exports;
} catch (e) {}
},
use: {
loader: 'babel-loader',
options: {
babelrc: false,
configFile: false,
presets: ['@babel/preset-env'],
},
},
},
],
},
};
When using this approach, you'll need to ensure modern syntax is supported by
your minifier. Both Terser
and uglify-es
have an option to specify {ecma: 2017}
in order to preserve and in some cases
generate ES2017 syntax during compression and formatting.
Rollup
Rollup has built-in support for generating multiple sets of bundles as part of a single build, and generates modern code by default. As a result, Rollup can be configured to generate modern and legacy bundles with the official plugins you're likely already using.
@rollup/plugin-babel
If you use Rollup, the
getBabelOutputPlugin()
method
(provided by Rollup's
official Babel plugin)
transforms the code in generated bundles rather than individual source modules.
Rollup has built-in support for generating multiple sets of bundles as part of
a single build, each with their own plugins. You can use this to produce
different bundles for modern and legacy by passing each through a different
Babel output plugin configuration:
// rollup.config.js
import {getBabelOutputPlugin} from '@rollup/plugin-babel';
export default {
input: 'src/index.js',
output: [
// modern bundles:
{
format: 'es',
plugins: [
getBabelOutputPlugin({
presets: [
[
'@babel/preset-env',
{
targets: {esmodules: true},
bugfixes: true,
loose: true,
},
],
],
}),
],
},
// legacy (ES5) bundles:
{
format: 'amd',
entryFileNames: '[name].legacy.js',
chunkFileNames: '[name]-[hash].legacy.js',
plugins: [
getBabelOutputPlugin({
presets: ['@babel/preset-env'],
}),
],
},
],
};
Additional build tools
Rollup and webpack are highly-configurable, which generally means each project must update its configuration enable modern JavaScript syntax in dependencies. There are also higher-level build tools that favor convention and defaults over configuration, like Parcel, Snowpack, Vite and WMR. Most of these tools assume npm dependencies may contain modern syntax, and will transpile them to the appropriate syntax level(s) when building for production.
In addition to dedicated plugins for webpack and Rollup, modern JavaScript bundles with legacy fallbacks can be added to any project using devolution. Devolution is a standalone tool that transforms the output from a build system to produce legacy JavaScript variants, allowing bundling and transformations to assume a modern output target.