Duplicated ESM and CJS package in bundle

February 18, 2024

Note: for context we’re in a Next.js TypeScript project, using Webpack as a bundler, but I could see this happening with similar tools.

The problem occurred with the firebase package, but again that could happen with other packages.

So we upgrade the Firebase SDK by a few minor versions, and suddenly, our JS bundle size blows up. Like, 50 kB more of (gzipped) JS shipped on every page. Not good.

Luckily we have tests to catch this kind of thing.

Further investigation shown that we were shipping @firebase/app and @firebase/auth twice. 🤔

The problem

We use next-firebase-auth to integrate Firebase Auth with Next.js. next-firebase-auth imports specifically firebase/app and firebase/auth.

In our own code, we use import to import our dependencies:

import { getApp } from 'firebase/app'
import { getAuth } from 'firebase/auth'

But next-firebase-auth, while they do the same in their TypeScript source code, is actually bundled down (also with Webpack) to a CJS file.

The code is minified, but you can see it uses require:

324:e=>{e.exports=require("firebase/app")},610:e=>{e.exports=require("firebase/auth")}

The problem is that the version of the Firebase SDK we upgraded to contains this PR, that makes @firebase/auth export both ESM and CJS variants of their browser bundle, whereas before they only exposed the ESM version for the browser.

Concretely, this means that before this PR, the package.json of @firebase/auth looked like:

{
  "exports": {
    ".": {
      "default": "./dist/esm2017/index.js"
    }
  }
}

And after:

{
  "exports": {
    ".": {
      "default": "./dist/esm2017/index.js",
      "browser": {
        "require": "./dist/browser-cjs/index.js",
        "import": "./dist/esm2017/index.js"
      }
    }
  }
}

Because initially there was no browser entry, Webpack picked the default value for both import and require, which turns out to be the ESM bundle.

However after that change, we now have a different bundle configured depending if it’s imported with import or require. As documented Webpack will map import calls to the file under import in the package.json, and require to the require field, which makes sense.

However this is a problem for us as we saw earlier, we use import in our own codebase, but the distribution bundle of next-firebase-auth (like probably many other packages in the ecosystem) only comes with a CJS file using require.

This means our own code will use @firebase/auth/dist/esm2017/index.js, while next-firebase-auth will use @firebase/auth/dist/browser-cjs/index.js.

Not only this increases our bundle size unnecessarily, but it breaks the Firebase SDK as it depends on shared global state, and now different parts of the codebase point to a different, isolated version of the SDK.

This sucks, and nobody’s to blame really

It’s just a result of the giant fracture in the ecosystem between CJS and ESM imports. It’s probably for the best, and I look forward to ESM being widespread enough that we don’t encounter those problems, but the transition is long and painful. It’s been 3-4 years I’m dealing with this kind of issues as a package maintainer, and they tend to be particularly time consuming, and takes away time to fix real problems or implement new features.

The solution

As far as I’m concerned, for that particular instance of this problem, the solution was to configure Webpack to alias firebase/app and firebase/auth (the parts of the Firebase SDK used by next-firebase-auth) to their ESM bundle, so this same bundle gets used regardless if imported with import or require.

In the Webpack config:

module.exports = {
  resolve: {
    alias: {
      'firebase/app': require.resolve('firebase/app').replace('index.cjs.js', 'index.mjs'),
      'firebase/auth': require.resolve('firebase/auth').replace('index.cjs.js', 'index.mjs')
    }
  }
}

It’s something we’ll have to maintain as we update the Firebase SDK, if they were to change the layout of their distribution files, since this doesn’t bother parsing the package.json exports field, but it’s good enough.

Bonus

For reference, a related GitHub issue and discussion.

I’ve also tried using resolve.conditionNames as follows, as a more generic fix to force all packages to use the ESM build if present:

module.exports = {
  resolve: {
    conditionNames: ['import', 'default']
  }
}

This would have been great as it would prevent similar (but maybe less noticeable) duplication issues to happen in the dependency graph, however, as you can expect, this will break some packages (in my case some @babel/runtime imports), so I couldn’t go with that.

Want to leave a comment?

Join the discussion on Twitter or send me an email! 💌
This post helped you? Buy me a coffee! 🍻