I've recently been exploring performance issues at work, and one of my findings is that we aren't tree-shaking in the way I would have expected. This led me to discover that we're using barrel files throughout the codebase, which can lead to larger bundle sizes; more stuff to send down the wire; more code that browsers need to parse.
As part of my investigation, I discovered the source of a rather massive increase in our bundle size — all due to the use of a barrel file in a Jest test.
But first, I want to talk about how barrel files can slow down the running of your tests.
To see the effect on test performance, I created a project with 1,000 files and 1,000 tests. Each test imports a single file and makes a single assertion. In one variation, the tests import values that are exported from a barrel file and in the other they import directly from the modules. I tried this with Jest and also Vitest.
The setup
- Create a node project
- Create a src directory and a test directory
- Create n files that export a value.
- Create a barrel file that imports and exports all of those values
- Create n tests that either import directly from the files or from the barrel file.
// Note: I've cut some parts from this file to focus on the key stuff...
// Create the files that export a value
for (let i = 0; i < numFiles; i++) {
const content = `export const value${i} = ${i};\n`;
fs.writeFileSync(path.join(srcDirectory, `module${i}.js`), content);
}
const barrelFile = path.join(srcDirectory, "index.js");
let barrelContent = "";
// Add all the export statements into the barrelfile
for (let i = 0; i < numFiles; i++) {
barrelContent += `export { value${i} } from './module${i}.js';\n`;
}
fs.writeFileSync(barrelFile, barrelContent);
// Create the tests
for (let i = 0; i < numFiles; i++) {
// import from barrel file:
let content = `import {value${i}} from '../src/index.js';\n`;
// or import directly from `{value${i}} from '../src/module${i}.js';\n`;
content += `test('value${i} works', () => {
expect(value${i}).toBe(${i})
});\n`;
fs.writeFileSync(path.join(testDirectory, `module${i}.test.js`), content);
}
This setup makes it easy to test 100 or 10,000 files or whatever. Doing that on a MacBook Pro with M3 chip and 8GB memory, it took approximately 20 seconds to run 1,000 tests with the barrel file and 4 seconds when not using the barrel file.
Why is testing so much slower with barrel files?
Jest doesn't tree shake like Webpack does and Vitest appears to behave the same way, so they end up importing all of the files exported from
the barrel file even if they aren't used in a given test. (Aside: I'm more familiar with Jest so it's possible I missed an opportunity
to tree shake with Vitest; however, I did create a vitest.config file with treeshake: true
and it still imported all the unused files).
Say you have a setup like this:
// index.js is a barrel file that exports lots of values from lots of files
export { value1 } from "./module1.js";
export { value2 } from "./module2.js";
export { value3 } from "./module3.js";
// etc...
// This test imports only 1 of those values
import { value1 } from "../src/index.js";
test("the value", () => {
expect(value1).toBe(1);
});
If you put console.log statements in other non-imported files, e.g. module2
or module3
and then run this test,
you'll see those console logs. Jest is working much harder for each test than it needs to.
To fix the problem, simply import files directly and bypass the barrel file. That said, you may want to avoid using barrel files to begin with, especially if you discover that tree shaking doesn't elinate unused code. In fact, I recently discovered that a barrel file at work was exporting a component that isn't used in prod and that nevertheless adds significantly to our production bundle.
A 900kb problem
Here's the file responsible for adding to our bundle size: can you spot the problem?
// index.js barrel file
export { ComponentA } from "@root/ComponentA"; // used in prod
export { ComponentB } from "@root/ComponentB"; // used in prod
export { ComponentMocks } from "@root/Component.mocks"; // 'Component.mocks' is used only for testing
You might suspect that the mock file could be an issue, since we presumably don't want mocks exported to production —
and yes, it is the problem. But why? Component.mocks
imports a file called mockCreators
;
mockCreators
imports createMockCreators
. Finally createMockCreators
imports faker.js, a library for creating mocks to use in tests.
This is a big library, and it's the source of our problem.
Because at least one production component imports either ComponentA or ComponentB, the mocks file is also imported into that production component and therefore ends up in the production bundle. This added almost 900kb even after being gzipped!
While you might like to assume that skilled engineers would catch this sort of problem during code review, I think that relies a little too much on people not making ordinary mistakes — or simply not knowing the consequences of barrel files. In this case, that's certainly what happened, and it makes me think it's probably better to simply avoid using barrel files to begin with.