Polyfills and helpers - How to save your bundle size

As web programmers, we'd like to use most recent features. On the other hand, how to make our modern code work on older browsers that don't understand those features?

There are two tools for that:

  • Polyfills
  • Transpilers

Polyfills vs transpilers#

Polyfills#

A polyfill is code that updates/adds new funtions or properties.

For example, string.prototype.matchAll was first introduced in ES2020 and can not work on Chrome under 67 according to can-i-use. If your users are using Chrome under 67, there's no string.prototyp.matchAll, so such code will fail.

For this case, "polyfill" will "fills in" the gap and adds missing implmentation by modifying the prototype chain of String, without replacing the source code.

Write code like this:

const matchString = 'abbbbc'.matchAll(/a/, 'd');

Then run it through a transpiler such as Babel:

require("core-js/modules/es.string.match-all.js");

var matchString = 'abbbbc'.matchAll(/a/, 'd');

Transpilers#

A transpiler is a tool that translates your source code to another code. It parse modern syntaxs and rewrite it using older syntaxs to make them work in older browsers.

spread operatorasync/await, Class are some good examples of this case.

Do same thing with this code:

const extendsObj = { ...{ a: 'a' } };

You will get:

var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));

function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { (0, _defineProperty2["default"])(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }

var extendsObj = _objectSpread({}, {
  a: 'a'
});

Notice that there are some functions imported from @babel/runtime/helpers, which helps to transpil code. They're what I refer to as "Helpers".

Downsides#

We're glad to see that our code can run on older browsers (like IE). On the other hand, polyfills and helpers may also make our application a big boat.

Take a React application (within thousands of modules in node_modules) for example, we divide it to two parts to discuss:

  • NPM React Component in node_modules directory
  • The Application itself

Helpers should never be inlined#

When transpiling, helper functions will be added to every file that requires it. This duplication is sometimes unnecessary.

In babel, this is where the @babel/plugin-transform-runtime plugin comes in. And you should use it like this way:

"presets": [
  ["@babel/preset-env", {
    // Disable useBuiltIns by default
    "useBuiltIns": false,
  }]
],
"plugins": [
  [
    "@babel/plugin-transform-runtime",
    {
      "absoluteRuntime": false,
      "helpers": true, // Helpers will not be inlined
    }
  ]
]

If you are using swc, enable non-inlined helpers in your .swcrc:

"jsc": {
  "externalHelpers": true
}

Helpers version resolution#

If a module is resolved to the same path, bundlers like webpack and rollup will bundle it once.

Let's say you have @swc/helpers@0.2.0 (the helpers be used by swc) installed in NPM package and @swc/helpers@0.3.0 in your application root.

They are considered as different modules for different resolved paths. Sometimes, this duplication is unnecessary.

You can tackle with this by adding resolutions (if you're using yarn), or pnpm.overrides (if you're using pnpm) field in your package.json file of application.

"name": "react-application",
"pnpm": {
  "overrides": {
    "@swc/helpers": "^0.3.0"
  }
}

There also exists a more elegant way: creating modules aliasas for helpers. For example:

// webpack.config.js
module.exports = {
  //...
  resolve: {
    alias: {
      "@swc/helpers": path.resolve(__dirname, 'node_modules'),
    }
  }
}

Should my NPM package polyfilled#

I prefer not to do this. A react application will not know whether all the NPM packages it depends are fully polyfilled or not. Even though all NPM packages are fully polyfilled, react applications must be fully polyfilled to prevent breakings.

There are two ways to make it more flexible:

  • Pre-bundling (I will cover this in another post)
  • Polyfilled all features based on environment

Polyfilled all features#

If you are using babel, it's convenient to add @babel/preset-env. And configure your .babelrc file like this:

"presets": [
  ["@babel/preset-env", {
    "target": "79",
    "useBuiltIns": "entry",
    "corejs": "3.8"
  }]
]

For swc users, configure your .swrrc file like this:

{
  "env": {
    "targets": {
      "chrome": "79"
    },
    "mode": "entry",
    "coreJs": "3.8"
  }
}

But it is not really perfect. There exists a case that a "polyfill" is not used but polyfilled.

To reduce the bundle size, you can only load these polyfills for browsers that require them, so that the majority of the web traffic globally will not download these polyfills.

Further reading#

Subscribe to the newsletter

Get emails from me about web development, tech, and early access to new articles. I will only send emails when new content is posted. No spam.