← Back to Tips & Tricks

Tips & Tricks

The Hidden Cost of Barrel Imports

Tree-shaking saves your bundle, but it can’t save your tests, your build, or your IDE. Barrel files make everything pay for everything.

What barrel imports are

A barrel is an index.ts that re-exports from other files so consumers can import from a single path:

// components/index.ts
export { Button } from "./Button";
export { Modal } from "./Modal";
export { DataGrid } from "./DataGrid";
export { MockProvider } from "./testing/MockProvider";

Convenient. But that convenience has a cost that doesn’t show up in your production bundle — it shows up everywhere else.

Tree-shaking only works after compilation

Bundlers like Vite, Webpack, and Rollup can tree-shake unused exports out of the final output. If you import Button from a barrel that also exports DataGrid, the production bundle won’t include DataGrid. So far, no problem.

But tree-shaking is a compilation step. It happens at the end, after every module in the barrel has already been resolved, parsed, and evaluated. In any context where code runs without a bundler — and there are more of these than you think — the barrel pulls in everything it re-exports.

Tests pay the full price

Test runners like Jest and Vitest operate in a Node context. When your test file imports Buttonfrom a barrel, Node resolves the barrel’s index.ts and executes every module it re-exports. Every component, every utility, every dependency of every dependency — all initialised before your first test case runs.

This is where it gets painful. If your barrel re-exports test mocks, fixture factories, or anything with heavy setup logic, every test file that touches the barrel pays for all of it. A test that only needs Button ends up loading your entire mock infrastructure, database fixtures, and that one component that pulls in a charting library.

The result is test suites that take seconds to start before a single assertion runs. Multiply that across hundreds of test files and the overhead dominates your CI time.

Build times compound

Even with tree-shaking, the bundler still has to resolve and parse every file in the barrel to determine what can be removed. For TypeScript projects, the type checker follows the same graph — a barrel that re-exports 200 modules means tsc has to load and check all 200, even if the consuming file uses one.

In a mono-repo this compounds. Package A barrels 50 modules. Package B imports one thing from A and barrels its own 30 modules plus A’s barrel. Package C imports from B. By the time the compiler reaches C, it’s resolving a transitive graph of hundreds of files, most of which are irrelevant to what C actually uses.

IDE performance degrades

Your IDE’s language server does the same resolution the compiler does. Every time you type an import from a barrel, the language server loads every re-exported module to provide completions, hover info, and go-to-definition. Large barrels mean slower autocomplete, delayed diagnostics, and higher memory usage.

This is especially noticeable in projects that barrel aggressively — a deeply nested barrel chain can cause the language server to hold the entire project graph in memory just to serve completions in one file.

Hot reload and dev servers suffer

Dev servers like Vite serve files on demand over native ESM. In theory, only the files you navigate to are loaded. In practice, a barrel import forces the dev server to transform every file the barrel re-exports the first time any one of them is requested. This inflates cold-start times and makes HMR slower — a change to one file in the barrel can invalidate the barrel itself, causing the dev server to re-evaluate every re-export.

Dependency graph analysis breaks down

Tools that determine which packages are affected by a change rely on the import graph. Barrels smear the graph: because every consumer of the barrel technically depends on everything the barrel re-exports, affected-file detection becomes overly broad. A change to DataGrid triggers rebuilds for every package that imported Button from the same barrel — even though they never used DataGrid.

Large libraries already know this

This isn’t a theoretical concern — major libraries have documented it and changed their recommended import patterns because of it.

MUI (Material UI) has a dedicated guide on minimizing bundle size that explicitly tells you to avoid the top-level import and use deep path imports instead:

// ❌ pulls in every MUI component
import { Button, TextField } from "@mui/material";

// ✅ only loads what you use
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";

Lodash is the classic example — importing from lodash loads the entire library, which is why lodash-es and per-function imports (lodash/debounce) exist. date-fns made the same shift, moving to individual function exports. Ramda, RxJS, and AWS SDK v3 all restructured around direct imports to avoid the barrel problem.

When libraries with dedicated performance teams are telling you not to use their own top-level import, that’s a strong signal.

What to do instead

Import directly from the source file:

// instead of
import { Button } from "@myorg/components";

// import from the file
import { Button } from "@myorg/components/Button";

Use package.json exports to define explicit entry points so consumers have clean paths without a barrel:

{
  "exports": {
    "./Button": "./src/Button.tsx",
    "./Modal": "./src/Modal.tsx",
    "./DataGrid": "./src/DataGrid.tsx"
  }
}

If you must keep a barrel for backwards compatibility, at minimum keep test utilities and mocks in a separate entry point like @myorg/components/testing so production code never pays for test infrastructure.

The convenience of a single import path is real, but it’s a developer experience trade-off that costs you in every environment except the one with tree-shaking — and that one was already fine.