tl;dr: To bring modern JavaScript to our libraries, we should adopt a new
"browser2017"
conditional export key. The"browser2017"
key points to modern code that targets modern browsers, without the polyfills that bloat our bundles. This change requires support from bundlers and adoption from package authors.
Background
Although modern browsers represent over 90% of web traffic, many websites still transpile JavaScript to ES5 to support the <10% still stuck on older browsers like IE 11. To do this, most websites transpile their code and deliver polyfills which reimplement functionality already included in modern browsers. This produces larger bundles, which mean longer load and parse times for everyone.
The module/nomodule pattern
In 2017, the module/no module pattern began being recommended as a solution to this problem. Leveraging the fact that newer browsers support <script type="module">
and older browsers don’t, we can do the following:
<script type="module" src="bundle.modern.js"></script>
<script nomodule src="bundle.legacy.js"></script>
This technique serves newer browsers the ES2017 index.modern.js
bundle and older browsers the polyfilled ES5 index.legacy.js
bundle. Though there's a bit more complexity involved, it provides a mechanism for the majority of users to take advantage of ES2017 syntax without needing to rely on user agent detection or dynamic hosting.
Problem
Though the module/nomodule pattern has introduced a mechanism to serve modern bundles, there’s still one glaring problem: virtually all our third-party dependencies (and thus the majority of our JavaScript code) are stuck in ES5. We’ve left transpilation to package authors, but have established no mechanism for them to publish a modern version of their code. Until we develop a standard for doing so, applications cannot truly reap the benefits of modern JavaScript. Conditional exports can provide that standard.
Proposal: "browser2017"
Conditional Export
In January 2020, Node v13.7.0 announced official support for conditional exports. Conditional exports allow packages to specify per-environment entry points via an "exports"
package.json field. For example, a library might do the following:
// my-library's package.json
{
"name": "my-library",
"main": "./index-node.js",
"module": "./index.production.mjs",
"browser": "./index.production.js",
"exports": {
"node": "./index-node.js", // Node.js build
"development": "./index.development.mjs", // browser development build
"default": "./index.production.js" // browser ES5 production build
}
}
From here, based on which conditions get matched, a bundler or runtime like Node.js can select the most appropriate entry point to use when resolving the module.
With conditional exports introduced, we finally have an opportunity for packages to offer a modern version of their code. To that end, we propose standardizing a new conditional exports key, "browser2017"
:
// my-library's package.json
{
"name": "my-library",
"main": "./index-node.js",
"module": "./index.production.mjs",
"browser": "./index.production.js",
"exports": {
"node": "./index-node.js", // Node.js build
"development": "./index.development.mjs", // browser development build
"browser2017": "./index.browser2017.mjs", // browser modern production build
"default": "./index.production.js" // browser ES5 production build
}
}
The "browser2017"
key specifies an ES module entry point that uses JavaScript features available in browsers that support <script type="module">
. That translates to Chrome 61+, Edge 16+, Firefox 60+ and Safari 10.1+.
These targets pair cleanly with the module/nomodule pattern, eliminating polyfills for:
- All ES2015 features (classes, arrow functions, maps, sets) excluding tail-call optimization
- All ES2016 features (array.includes(), exponentiation operator)
-
Most ES2017 features (async/await, Object.entries())
Note: The
"browser2017"
key only approximates ECMAScript 2017. This is because browsers implement the ECMAScript spec independently and arbitrarily. For example, no browsers other than Safari have implemented ES2015’s tail-call optimization and both Firefox and Safari have broken/partial implementations of ES2017’s shared memory and atomics features.
Naming the key "browser2017"
might seem confusing, since its semantics don’t map exactly to ECMAScript 2017 but rather serve as an alias to the browsers that support <script type="module">
. However, the name clearly communicates to developers that it represents a certain syntax level, and that syntax level most closely corresponds to ES2017.
Feature Supported | Chrome | Edge | Firefox | Safari |
---|---|---|---|---|
<script type="module"> | 61+ | 16+ | 60+ | 10.1+ |
All ES2017 features (excluding atomics+shared memory) | 58+ | 16+ | 53+ | 10.1+ |
Packages can generate this entry point using either @babel/preset-env’s targets.esmodules option, or the TypeScript compiler’s ES2017 target.
Library Size by Transpilation Target
One of the benefits of publishing modern JavaScript is that newer syntax is generally much smaller than polyfilled ES5 syntax. The table below shows size differences for some popular libraries:
Library | ES5 | "browser2017" |
---|---|---|
bowser | 25.2 KB | 23.3 KB (-7.5%) |
swr | 24.0 KB | 14.4 KB (-40.0%) |
reactstrap | 225.0 KB | 197.5 KB (-12.1%) |
react-popper | 11.3KB | 9.75KB (-13.7%) |
*Data gathered using unminified and uncompressed output
Furthermore, some library authors are forced to write in legacy syntax, as transpiled modern code can sometimes be significantly slower or larger than its legacy counterpart. Establishing a "browser2017"
entry point would enable these authors to instead write in modern syntax and optimize for modern browsers.
Adoption from Package Authors
For many package authors who already write their source code in modern syntax, supporting this could be as simple as adding another target to their build process. For example, if Rollup is used:
Example rollup.config.js
export default [
// existing config
{
input: 'src/main.js',
output: { file: pkg.main, format: 'es' },
plugins: [ babel({exclude: 'node_modules/**'}) ]
},
// additional "browser2017" config
{
input: 'src/main.js',
output: { file: pkg.exports.browser, format: 'es' },
plugins: [
babel({
exclude: 'node_modules/**',
presets: [['@babel/preset-env', {
targets: { "esmodules": true }
}]],
})
]
}
];
Support from Bundlers
Before it can be consumed by applications, the "browser2017"
conditional export needs support from existing tooling. Currently however, most tools have yet to implement support for conditional exports at all. This is documented below:
Bundler / Tool | Export Maps | Conditional Maps |
---|---|---|
Node.js | shipped | shipped |
Webpack | implemented | implemented |
Rollup | not implemented | not implemented |
Browserify | not implemented | not implemented |
Parcel | not implemented | not implemented |
esm | not implemented | not implemented |
Snowpack | implemented | not implemented |
Vite | not implemented | not implemented |
es-dev-server | not implemented | not implemented |
Drawbacks
The "browser2017"
conditional export enables publishing ES2017 syntax, but what about ES2018+ features? We would still pay the cost of transpiling features like object rest/spread and for await...of. Furthermore, the "browser2017"
key isn't futureproof. By the time ES2025 arrives, "browser2017"
may be considered legacy.
Alternative Solution: Multiple Entry Points by Year
One solution is to add additional entry points each year:
// my-library's package.json
{
"name": "my-library",
"main": "./index-node.js",
"module": "./index.production.mjs",
"browser": "./index.production.js",
"exports": {
"node": "./index-node.js",
"development": "./index.development.mjs",
"browser": {
"2020": "./index.2020.mjs",
"2019": "./index.2019.mjs",
"2018": "./index.2018.mjs",
"2017": "./index.2017.mjs"
},
"default": "./index.production.js"
}
}
Though the module/nomodule pattern cannot take advantage of "browser2018"
+ keys, other techniques can. For example, a website can serve ES2019 code by doing any of the following:
- Using user-agent sniffing
- Dynamically loading bundles
- Choosing to entirely abandon support for older browsers.
Drawbacks
Drawbacks of ES2018+ Differential Loading Techniques
However, each of the aforementioned mechanisms have their drawbacks and thus have not garnered much adoption. User-agent sniffing is complex and error-prone, and dynamic loading does not allow for preloading (source). A static solution was proposed in 2019, but was met with standardization challenges. At the earliest, import maps might give us a technique for a "browser2021"
key or some form of differential loading.
Diminishing Improvements in Size
It’s also worth highlighting that ECMAScript versions after ES2017 contain fewer features with less adoption, so additional entry points might not have a significant impact on bundle size.
Features by ECMAScript Year
es2015 | es2016 | es2017 | es2018 | es2019 | es2020 | es2021+ |
---|---|---|---|---|---|---|
const, let | ** operator | async/await | Object Spread/Rest | Array.flat, Array.flatMap | String.matchAll | String.replaceAll |
Template literals | Array.includes | String padding | Promise.finally | Object.fromEntries | BigInt | Promise.any |
Destructuring | Object.{values, entries, …} | RegExp features | Optional catch binding | Promise.allSettled | Logical Assignment | |
Arrow functions | Atomics | for await...of | globalThis | … to be decided | ||
Classes | Shared Memory | Optional chaining | ||||
Promises | Nullish coalescing | |||||
... a lot more |
Library Size by Transpilation Target
Compared to the "browser2017"
target, transpiling to a "browser2019"
target tends to result in only very small reductions in size.
Library | ES5 | "browser2017" | "browser2019" |
---|---|---|---|
bowser | 25.2 KB | 23.3 KB (-7.5%) | 23.3 KB (-0%) |
swr | 24.0 KB | 14.4 KB (-40.0%) | 13.8 KB (-4.2%) |
reactstrap | 225.0 KB | 197.5 KB (-12.1%) | 197.5 KB (-0%) |
react-popper | 11.3KB | 9.75KB (-13.7%) | 8.98 KB (-7.9%) |
*Data gathered using unminified and uncompressed output
Maximum Polyfill Size by Transpilation Target
In practice, the size of polyfills depends on which features are actually used. However, we can estimate the maximum size of polyfills (the size assuming every unsupported feature is polyfilled) for each transpilation target. This data is useful for comparison, but it should be noted that the values for es2017 and es2019 include significant over-polyfilling as a result of technical constraints that can be addressed.
Transpilation Target | Browsers | Maximum Polyfill Size |
---|---|---|
ES5 | IE11+ | 97.6 KB |
"browser2017"
|
CH 61, Edge 16, FF 60, SF 10.1 | 59.5 KB |
"browser2019"
|
CH 73, Edge 79, FF 64, SF 12.1 | 39.5 KB |
* Data gathered using minified and uncompressed output. Includes only ECMAScript features polyfilled by babel+core-js.
Complexity
At least for now, yearly entry points might only further complicate the package authoring process. They would require year-to-year community-wide agreements upon what browser versions are considered part of a given year, and for package authors to correctly follow those definitions. Given the decentralized nature of the JavaScript ecosystem, it’s important to take into account that simpler solutions are easier to adopt.
In the future, it might make sense to add another entry point only once a substantial amount of new features have been released, or after a new differential loading mechanism becomes available. At that point, we could extend the less granular "browser2017"
, "browser2021"
, and "browser2027"
entry points, with each year serving as an alias for a set of targeted browsers. Tools like @babel/preset-env could potentially adopt these aliases and abstract their precise definitions.
Alternative Solution: "esnext"
entry point
Note: This is nearly identical to Webpack’s proposed “browser” entry point
We can see that:
- Application developers are the only ones who can know their target browsers
- Maintaining multiple package variations is a pain point for package authors
- Application developers already have transpilation integrated into their build process for their own code
Given the above, what if we shift the burden of transpilation away from package authors and onto application developers? A generic "esnext"
export map key could point to code containing any stable ECMAScript feature as of the package’s publish date. With this knowledge, application developers could transpile all packages to work with their target browsers.
// my-library's package.json
{
"name": "my-library",
"main": "./index-node.js"
"module": "./index.production.mjs",
"browser": "./index.production.js",
"exports": {
"node": "./index-node.js",
"development": "./index.development.mjs",
"esnext": "./index.esnext.mjs",
"default": "./index.production.js"
}
}
Both package authors and application developers would no longer need to worry about what syntax level a package is published in. Ideally, this solution would enable JavaScript libraries to always provide the most modern output - even as the definition of “modern” changes.
Drawbacks
Migrating to Transpiling node_modules
The JavaScript ecosystem has a long-ingrained belief that we shouldn’t have to transpile node_modules
, and our tooling reflects this. Since libraries are already transpiled prior to being published, most applications have configured Babel to exclude transpiling node_modules
. Moving to an "esnext"
entry point would require application developers to move away from pre-transpiled dependencies, instead adopting slower fully-transpiled builds. The build impact could be alleviated to some degree through caching and limiting transpiling to production builds. Some tools have already adopted this approach, including Parcel and Create React App. This change would also require tooling changes to selectively transpile only packages that expose an “esnext” entry point.
Silent Breakages
A moving "esnext"
target has the potential to cause silent breakages in applications. For example, ES2021 could introduce Observable to the standard library. If an npm library starts to use Observable in its "esnext"
entry point, older versions of Babel would not polyfill Observable but output no errors or warnings. For application developers who don’t update their transpilation tooling, this error would go uncaught until reaching testing or even production. Adding more metadata in our package.json could be one approach to solving this. Even with this information, it may still be difficult or impossible to reliably determine the publish date for an installed package: npm injects the publish date into local package.json files when installing, but other tools like Yarn do not.
Solutions Comparison
Solution | Pros | Cons |
---|---|---|
browser2017 |
|
|
browser2017 browser2018 browser2019 ... |
|
|
esnext |
|
|
Looking Forward
A pre-transpiled "browser2017"
conditional export unlocks most of the potential benefits of modern JavaScript. However, in the future we might need subsequent "browser2021" and "browser2027" fields.
In contrast, "esnext"
is futureproof but requires a solution that addresses silent breakage and versioning consensus before it can be viable. It also requires many changes in existing tooling and configurations.
Our applications stand to benefit from serving modern JavaScript. Whichever mechanism we choose, we need to consider how it affects each part of the ecosystem: bundlers, library authors, and application developers.
I'd love to hear your thoughts 😃! Feel free to leave a comment or suggestion below 👇.
Other Resources
- @sokra Introduces conditional exports to Webpack
- On Consuming and Publishing ES2015+ packages - Henry Zhu
- Deploying ES2015+ Code in Production - Philip Walton
- Modern Bundling - Jovi De Croock
- Create React App Introduces Transpilation of node_modules
- The Babel Podcast: Compiling Your Dependencies - Henry Zhu and Jason Miller
- Kangax compat-table
- Twitter Discussion about jsnext:main
Top comments (11)
Even though there are drawbacks, shifting transpiling responsibility to application developers seems the most escalable approach. I think these drawbacks of potential silent breakages and transpiling errors can be vastly mitigated once the tooling and community adopts this extensively.
I think the esnext option makes the most sense. There is no way for a library to know what its users are targeting so it can only make best guesses and support a couple of different options. It makes more sense to leave the transpilation to the consumer who can have more control over the level of support they need.
This makes things easier for the library developers as they can just focus on targeting the current browsers and, with the right tooling, the consumers of the library can easily transpile that code to support whatever configuration they need. No more messing around getting commonjs modules and esmodules to work together and much less useless code that adds unneeded browser support when you are creating a bundle for modern browsers. We can all just write code once for modern browsers with the latest widely supported features and then let the build tools handle legacy support.
I feel like the issue of outdated transpilation could be alleviated by having the build tools check for updates automatically and trigger warnings. Alternatively, assuming all transpilation will be done through babel, the library could add babel as a peer dependency with a minimum working version that would then trigger missing dependency warnings if the consumer uses an older version which doesn't support the newer features.
Excellent articl with an obvious amount of effort, research, and experience. 👍
Honestly TLDR but by glancing I cannot stress enough how bad is a world where you need to build an application for "js2020", "js2019", "js2018", "js2017" and so on. Soon enough we'll have the division of python 2/3 in the javascript world in libraries.
"We’ve left transpilation to package authors, but have established no mechanism for them to publish a modern version of their code."
Here you have the solution of your problem : just don't transpile libraries. Serve them fresh with the latest tc39 stable features and don't assume how people want to consume them. If they want to transpile down to es5, let them do. If some library author publishes a library with older javascript features, it'll work as much thanks to backward compatibility.
Don't assume on how people will use your code. Serve it clean no matter what. Problem solved.
tl, but if I expect loyalty from users, wouldn't it be better to use something near
esnext
, and notify users to use latest browsers, if some features are not supported?Especially the case with web apps.
However, for static websites (probably with API's), I believe it should be able to run even with JavaScript disabled. (Still, CSS supports can still be an issue, not sure about HTML5.)
Also, can anyone please conclude, what the preferred settings for Babel /
tsc
/ browserify / Snowpack? (I have used Rollup, but I feel it can be unreliable.)Another thought -- (pre-)building on the CDN based on User Agents?
User Agents are an increasingly unreliable mechanism for determining the compile target. Also,
esnext
does not mean the latest browsers, it means the latest spec version. The current versions of Safari and Firefox are missing support for some ES2019 features, let alone Firefox ESR or Safari for iOS. Anesnext
target is not connected to any real-world usage, it's purely a theoretical syntax level based on in-flight specification work. Ultimately, the browser implementors decide which JavaScript features ship and which don't.Very good idea and important topic to promote. In general I can't wait till babel will be thing of the past for normal developers.
This is super insightful! I love that there's movement behind standardizing solutions like this. Question: you mention that Webpack has implemented conditional exports... any idea where I might be able to try that out? I'm assuming it'll be in v5?
Yup, v5!
I miss the pipeline operator again
One solution is to adopt ReactNativeWeb :p
RN libs are published untranspiled, so it's already the responsibility to the app to transpile those libs