Bringing Modern JavaScript to Libraries

garylchew

Gary Chew

Posted on July 31, 2020

Bringing Modern JavaScript to Libraries

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:

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
  • Simplest solution
  • Precise definition tied to a set of browsers
  • Applications do not need to transpile dependencies
  • Requires minor changes in tooling/configuration
  • Package authors control how their package gets transpiled
  • Misses out on ES2018+ syntax
  • We might need to introduce a “browser2025” entry point in the future
  • Does not support all ES2017 syntax; can be misinterpreted
browser2017 browser2018 browser2019 ...
  • Gives applications power to target any syntax level
  • Applications do not need to transpile dependencies
  • Package authors control how their package gets transpiled
  • Requires minor changes in tooling/configuration
  • There is currently no static differential loading mechanism for serving ES2018+ syntax
  • ES2018+ entry points currently would not significantly reduce size
  • Complicates package authoring process
esnext
  • Gives applications full power in determining their target browsers
  • Future-proof; libraries will always be using the latest syntax
  • Simplifies package authoring process
  • There is currently no static differential loading mechanism for serving ES2018+ syntax
  • Slow production builds; can be alleviated with caching
  • Tooling must be built to selectively transpile node_modules
  • Can cause silent breakage for package users
  • Package authors have no control over how their packages get transpiled

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

💖 💪 🙅 🚩
garylchew
Gary Chew

Posted on July 31, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related