Medium wave · 540–1600 kHz78 stn
1501kHz

Barrel Files Are a Tax on Every Import

A single small brass valve being opened at the base of an enormous dark steampunk manifold, forcing steam to roar through hundreds of copper pipes branching to every machine in a vast fog-filled hall, each pipe lighting up neon purple and teal as pressure floods it, only one pipe actually needed

Somewhere in your codebase there is a file that looks like this:

// src/components/index.ts
export * from './Button';
export * from './Card';
export * from './DataTable';
export * from './DatePicker';
export * from './Modal';
export * from './RichTextEditor';
// ...forty more lines

It exists so that every other file can write one tidy line instead of six:

import { Button } from '@/components';

That is a barrel file, and the tidy line is a lie. It reads like “give me the button.” What it actually says is “load the whole folder, run every module in it, and then hand me the button.”

Barrels are a publishing format that got adopted as an organizing habit. Inside an application, they cost more than they save, and the costs show up in places nobody connects back to that one innocent index.ts.


What an import actually does

JavaScript modules are not header files. Importing a module means evaluating it, and evaluating it means evaluating everything it imports first. There is no way to load “just the Button export” of a module that also re-exports RichTextEditor. The runtime has to resolve every export * to know which names exist, and resolving them means loading the modules behind them.

So import { Button } from '@/components' pulls in the editor, its toolbar, the date picker, the date library the date picker uses, the table, and whatever the table imports. Every module at the top of each of those files runs.

In a production build, a bundler can often undo some of this. In every other context, nothing does.


Where the bill shows up

The dev server

Vite and similar dev servers serve your source as native ES modules, one request per module, transformed on demand. A barrel import turns a page that needs 30 modules into a page that requests 400. Open the network panel on a cold dev load and count. The slow “first load after starting the server” that everyone accepts as normal is frequently a handful of barrels fanning out across the tree.

Your test runner

Jest and Vitest evaluate real modules. A unit test for a pure formatting helper that imports from @/utils loads every utility, and if one of those utilities imports a chart library or an SDK client, your three-millisecond test now spends most of a second in module setup. Multiply by a few hundred test files and the barrels are a measurable share of your CI time.

It also breaks test isolation in a quieter way. Mocks have to cover modules your test never meant to touch, because they got loaded anyway.

Circular imports

Barrels make cycles almost effortless. A component inside components/ imports a sibling through @/components for consistency. The barrel imports that component. Now you have a loop, and depending on evaluation order you get this at runtime:

ReferenceError: Cannot access 'Card' before initialization

The error points at a line that looks completely correct, because the bug isn’t on that line. It’s in the shape of the graph. Teams lose afternoons to this, and the fix is almost always “import the file directly,” which is the thing the barrel was supposed to replace.

The production bundle

This is the one people assume is handled, and sometimes it is. Rollup, esbuild, and webpack can drop unused re-exports, but only when they can prove the skipped modules have no side effects. A module has side effects if anything at its top level does work beyond defining exports:

// Any one of these makes the module "impure" to a bundler
import './modal.css';
registerIcon('close', CloseIcon);
const formatter = new Intl.NumberFormat('en-US');
window.addEventListener('resize', onResize);

The CSS import alone is enough. Once a bundler can’t prove a module is pure, it has to keep it, and the barrel has just connected that module to every page that imported anything from the folder. The result is a homepage bundle carrying a rich-text editor because the header imported a Button from the same barrel.

You can declare purity in package.json:

{
  "sideEffects": ["*.css"]
}

That helps, but it’s a promise the bundler takes on faith. Get it wrong, say by marking a module pure that registers something at load time, and the bundler will silently delete behavior. That’s a worse bug than a fat bundle.


Measure before you argue

You don’t have to take any of this on faith. Three quick checks will tell you whether barrels are costing you anything.

Count modules on a cold dev load. Start the dev server, open a simple page with the cache disabled, and filter the network panel to scripts. If a page with one button requests hundreds of modules, follow the waterfall back to the barrel.

Time a trivial test. Pick the smallest pure-function test you have and run it alone:

npx vitest run src/utils/formatPrice.test.ts

If the reported setup or import time dwarfs the test time, look at what that test’s imports drag in.

Inspect the production bundle. Use your bundler’s analyzer and look at the chunk for a page that should be light. Any module you can’t explain is worth tracing back to its import site.


What to do instead

Import from the file

import { Button } from '@/components/Button';
import { formatPrice } from '@/utils/formatPrice';

It’s slightly longer. It’s also exactly what the code means, it can’t create a hidden cycle through a shared index, and every tool in the chain (dev server, test runner, bundler, editor) does less work. Editors auto-complete deep paths just as easily as shallow ones, so the typing cost is close to zero.

Make it a rule, not a preference

Habits drift back toward barrels because editor auto-import will happily pick the shortest path. A lint rule stops the drift:

// eslint.config.js
export default [
  {
    rules: {
      'no-restricted-imports': [
        'error',
        {
          patterns: [
            {
              group: ['@/components', '@/utils', '@/hooks'],
              message: 'Import from the specific file, not the folder index.',
            },
          ],
        },
      ],
    },
  },
];

Pair it with import/no-cycle from eslint-plugin-import if you want cycles caught before they become runtime errors. That rule is slow on large codebases, so it’s a reasonable candidate for CI rather than the editor.

Delete the barrels incrementally

You don’t need a big-bang migration. Turn on the lint rule as a warning, fix call sites as you touch them, and delete each index.ts once nothing imports it. A codemod can do the bulk of the rewrite if you’d rather finish in one pass, but the incremental path works fine and never blocks anyone.

If you’re on Next.js

Next.js has an optimizePackageImports option in next.config that rewrites barrel imports from listed packages into direct imports at build time, and it applies to a set of popular icon and utility libraries by default. It’s a useful patch for third-party barrels you can’t change. It’s not a reason to keep writing your own.


Where barrels are actually fine

A barrel earns its keep at a package boundary. If you publish a library, a single entry point that defines the public API is correct: it tells consumers what’s supported and what’s internal, and it lets you move files around without breaking anyone. Even then, mark the package’s side effects accurately and consider exposing subpath exports so consumers can skip the index when they want to.

The mistake is applying that pattern inside an application, where there are no external consumers and no API to protect. There, the barrel isn’t defining a boundary. It’s just a shortcut that every importer pays for.


The takeaway

An import should cost what it says. import { Button } from '@/components' says one component and costs a folder. Import from the file, enforce it with a lint rule, and save barrels for the one place they belong: the front door of a package other people install.