Burn the Barrel!
TL;DR: avoid using barrel files — it could improve your app’s runtime, build-time and test-time performance.
Background 🏞️
A few months ago, I came across a series of articles by Steven Lemon to do with improving Jest performance. For the uninitiated, Jest is a JavaScript (JS) testing framework — it allows you to write unit-tests for your JS codes (including for UI component codes). Jest has been around for 7+ years, but it’s more performant than Vitest (despite their marketing).
The reason I was researching Jest-performance is that I’m part of a team that is building a brand new NextJS-based website, and our GitHub-action workflow was spending a lot of it’s time running our unit-tests. If we could speed up the tests, we can save time & money & improve the dev-experience (DX).
In Steven’s article Why is My Jest Test Suite So Slow?, among some of his other interesting findings is a discovery that barrel-files are a major reason for slow tests.
What is a barrel file? 🛢️📁
In JS, a barrel file is a module that re-exports a bunch of exports from other places:
// some-package/index.js
export * from './a/Accordion';
export * from './b/Badge';
export * from './c/Container';
...
This allows consumers of a package to write (for example):
import { Accordion } from 'some-package';
, instead of:
import { Accordion } from 'some-package/a/Accordion'
.
Another benefit of barrel files as they control the external interface to a package so that the consumer doesn’t need to care how some-package
is structured internally. Most libraries usually have a barrel file in the root directory containing everything they want to expose to the consumer.
So barrel-files are good because:
- Consumers can type-less when importing something
- Consumers can be blissfully ignorant about how a package is internally-structured.
But are there any down-sides to using barrel files? From Steven’s article:
“The problem is that Jest has no idea where the component we’re importing is located. The barrel file has intentionally obfuscated that fact. So when Jest hits a barrel file, it must load every export referenced inside it. This behavior quickly gets out of hand for large libraries like
@mui/material
.”
Our app wasn’t using a library as-large-as Material-UI, but it was using an internal library that had over 200 React components, which was growing each week. So I decided to test whether Steven’s finding’s applied in our use-case as well…
The Results 🏆
I wasn’t 100% sure what was going to happen. But given the detailed (and hopefully-truthful) analysis I had read, I was quietly hopeful that there would be an improvement.
Methodology
To try to get an apple-to-apple comparison, I measured the unit-test times in 2 scenarios with the same computer:
- Time taken to run unit-tests with an empty cache
- Time taken to run unit-tests with a populated cache
- Do the above tests with the before-code, then with the after (barrel-less) code.
The numbers below are averages from 5 test-runs for each scenario.
It’s worth noting that the computer I’m using is a brand new 16" MBP with M2 Pro CPU and 16GB — courtesy of my employer Mantel Group (it’s a great place to work). So this is best-case scenario. On Github, where our CI/CD workflow runs, these same tests take about 10x longer. So a 5% increase in performance is significant to us.
Bonus Benefit 🎁
There were 2 additional, unexpected, benefits to removing the barrel files:
- Our NextJS app’s page sizes (HTML + JS) decreased by between 5–10%. This has a material-benefit on the website’s user-experience.
- The build time on CI for the NextJS app decreased by about 25% (over 4 minutes to around 3 minutes — more data required).
Sample of page size improvement:
The hard part — converting the code
Converting the code from using barrel-files to not-using-barrel-files took a few hours one weekend. About 300 files were changed. The dependent-package’s package.json
file’s exports
went from this:
"exports": {
".": {
"import": {
"types": "./dist/src/index.d.ts",
"default": "./dist/src/index.js"
}
},
"./*": {
"import": {
"types": "./dist/src/*.d.ts",
"default": "./dist/src/*.js"
}
}
}
…to this:
"exports": {
"./*": {
"import": {
"types": "./dist/src/*.d.ts",
"default": "./dist/src/*.js"
}
}
},
All of the barrel files were removed too, to prevent accidental regression.
Summary
I love consuming barrel files, but their convenience comes with a performance price-tag, especially in NextJS static-apps. For smaller projects, you might not notice (or care). But for larger projects, consider whether the convenience is worth the increase in build-times, test-times and run times (page sizes).
Other barrel-file articles
- The Benefits and Disadvantages of Using Barrel Files in JavaScript (the author notes that there may be a performance impact)
- Your Next.js Bundle Will Thank You — excellent article which goes into more detail as to why barrel imports impact NextJS.
Update 6-Mar-2023
Apparently there’s a property called "sideEffects": false
that can be specified in a package.json
file, to tell Webpack/Terser that your package can be safely tree-shaken. I gave it a try on the codebase, and this is what I found:
- 208kB common JS → 207kB
- No impact on test times
Apparently "sideEffects"
is not a silver-bullet. It depends. It might work for your use case, so give it a try before deciding to remove barrel files completely.
Thanks to Wenny Hidayat for the review!