Article / 2026-08-07
One import reached 143 modules.
Barrel files are the most widely accepted performance mistake in TypeScript. Here is what they cost in three pinned public repositories, what happened when a codemod removed that cost, and exactly where the numbers stop applying.
On this page
Every TypeScript codebase of a certain age has them. A directory grows past a handful of files, someone adds an index.ts that re-exports everything in it, and from then on the rest of the codebase imports from the directory instead of from the file.
import { Client0 } from '@/clients'
A barrel is a single graph node that depends on everything behind it. Ask it for one symbol and you get the whole directory, plus whatever the modules behind it pull in from their own barrels.
Your production bundler tree-shakes this away, so the shipped output is fine. That is exactly why the problem survives for years: it is invisible in the metrics teams actually watch. Where it is not invisible is in the tools that do not tree-shake. A dev server resolves and transforms the graph eagerly. A type checker walks all of it. Both pay for every module the barrel dragged in, on every cold start.
The interesting question is not whether that is true in principle. It is how much it costs in real, well-maintained repositories, and whether a tool can remove that cost without breaking the code.
02 / The instrument
Measure first. Rewrite second.
unstave is a Rust CLI built on oxc. It builds the module graph of a TypeScript workspace, then answers one question per import site: how many modules does this import reach, and how many would it reach pointing directly at the module that declares the symbol?
The ratio is amplification. The difference is excess: modules pulled in for no reason.
Barrel amplification
┌──────────────────────┬───────┬──────┬─────────┬───────┬────────┬────────────┐
│ barrel ┆ sites ┆ cost ┆ excess ┆ worst ┆ amp ┆ rewritable │
╞══════════════════════╪═══════╪══════╪═════════╪═══════╪════════╪════════════╡
│ src/clients/index.ts ┆ 4920 ┆ 1047 ┆ 5136480 ┆ 1044 ┆ 349.0× ┆ 4920/4920 │
└──────────────────────┴───────┴──────┴─────────┴───────┴────────┴────────────┘
Not a bundler and not a linter. It runs no tsc, does no type inference, and says nothing about bundle size. It measures one thing and rewrites one thing.
The rewriting is where a tool like this earns or loses trust, so it is deliberately timid. unstave fix dry-runs by default and prints a unified diff. It touches only the bytes inside the import statements it rewrites: no reformatting, no reordering, no reflowing the rest of the file. Imports it cannot resolve unambiguously, namespace imports, cyclic or external re-exports, and barrels with observed side effects are left byte-identical and reported with a reason.
03 / Three repositories
Pinned commits, not fixtures.
Synthetic benchmarks prove a tool is fast. They do not prove it is right about code other people wrote. So this ran against pinned commits of three large, actively maintained public projects that were not written to make the tool look good.
| Project | Graph | Median | Strongest targeted barrel | Safe rewrite |
|---|---|---|---|---|
Vite 57fea00 |
1,546 modules20.9 MiB peak RSS | 68 ms miss38 ms hit | 5x peak18 total excess | 4 files5 imports |
TanStack Query 46d7f02 |
1,081 modules21.0 MiB peak RSS | 41 ms miss26 ms hit | 19x peak189 total excess | 14 files14 imports |
Astro fba468c |
2,858 modules44.7 MiB peak RSS | 154 ms miss93 ms hit | 143x peak574 total excess | 5 files5 imports |
Measured on an Apple M4 Pro, 14 cores, 24 GB RAM, macOS 26.5.2. Timings are median internal totals across three consecutive pairs. A cache miss means the on-disk cache was deleted first; it does not mean the operating-system page cache was flushed.
Two things stand out. Whole-workspace analysis of a 2,858-module monorepo takes 93 ms warm in under 45 MiB, which is fast enough to run on every save rather than in a nightly job. And Astro's worst site reaches 143 modules for a single import, in a real framework, from a barrel doing exactly what barrels are supposed to do.
04 / What the rewrites did
Verified by each project's own tools.
Finding a number is easy. The load-bearing claim is that the rewrite is safe, and that has to be checked by each project's toolchain rather than by the tool's own assertion that everything went fine.
Vite
One import of ESModulesEvaluator reached 20 modules where the direct definition path needs four: 5x amplification, 16 excess at that site. The plan rewrote five imports across four files. After a frozen, package-filtered install, Vite's own package build and typecheck completed, and the directly affected server source map suite passed all 10 tests. Re-analysis dropped the barrel out of the ranking and did not increase unresolved specifiers.
TanStack Query
The Svelte Query barrel reached 19 modules at its worst site and accumulated 189 excess across all sites. The plan rewrote 14 imports in 14 test files and skipped four symbols it could not prove; the skips matter as much as the rewrites. Afterwards the two remaining sites sat at 1x amplification and zero excess. svelte-check reported zero errors, the package suite passed 168 tests across 23 files including Vitest type checking, and ESLint reported zero errors on every rewritten file.
This one needed a flag: --condition @tanstack/custom-condition. Monorepos frequently point their exports maps at build output, so a source-graph tool that does not know the project's source condition fails to resolve cross-package imports and understates every number behind them. A suspicious pile of unresolved specifiers is almost always this.
Astro
The 143x barrel. Five imports rewritten across five source files, the filtered dependency build completed, and the 84 tests closest to the rewritten modules passed. A wider unit run passed 3,271 of 3,276; three failures and one cancellation needed fixtures outside the filtered install or live font downloads, and were unrelated to the rewrites. Those are disclosed in the methodology rather than dropped from the summary.
05 / What this does not prove
Being precise about the boundary.
Publishing numbers is only worth doing if the limits come with them.
Removing 574 excess modules from a barrel's import sites shrinks the graph a dev server and type checker traverse. It does not mean a build got proportionally faster. Nothing here measured application startup or production build duration, and any claim of that kind would need a different experiment.
The clones were filtered. Dependencies came from each pinned lockfile with a package filter for the affected workspace, rather than building every package in very large monorepos. That is also why unresolved-specifier and dead-export totals are excluded from the comparison: a filtered install cannot make every fixture and workspace package available.
Your numbers depend entirely on how your barrels are shaped. The synthetic workspace in the repository shows a 349x barrel because it was built to. Vite showed 5x. Same tool, same analysis, two orders of magnitude apart. There is no useful average here, only what your own graph looks like.
Every write test ran inside a disposable clone. git diff --check passed, each repository's own formatter accepted every rewritten file, and re-analysis showed no increase in unresolved specifiers.
06 / The bug real code found
Foreign code finds what fixtures miss.
Running against real repositories surfaced something the test suite did not. Astro and TanStack Query use NodeNext-style imports with explicit runtime extensions.
import { thing } from './thing.js'
The codemod was rewriting the specifier to the source file it resolved to, producing imports that typechecked but would not run. It also normalized semicolons, so a semicolon-free codebase got a diff its formatter immediately disagreed with. Small, but exactly the kind of noise that makes a team stop trusting a codemod.
A regression fixture landed alongside the fix, and explicit .js, .mjs, and .cjs specifiers plus the source file's semicolon convention are now preserved. That fix is the most valuable thing to come out of this validation, and a good argument for running any codemod against foreign code before believing it.
Full methodology, pinned commit hashes, boundaries, and before/after results are published alongside the source.
Open methodology and findings07 / Try it yourself
One command, nothing written.
The first step writes nothing and tells you whether you have a problem worth acting on.
cargo install unstave-cli
brew tap eddiesr93/unstave https://github.com/eddiesr93/unstave
brew install unstave
unstave barrels
If the ranking shows something real, unstave analyze --format html produces a single portable report with no CDN or network dependency, and unstave fix shows you the diff before it touches anything. There is also a Vite plugin that runs the analysis asynchronously, so startup and HMR never wait on it, and serves the live report at /__unstave in development.
npm install --save-dev @unstave/vite-plugin
unstave is MIT-licensed and early: v0.2.1, with interfaces that may still change before 1.0. If you run it and find something real, or something wrong, the discussions are the right place for it. Module count, top barrel, amplification, and what you changed is the most useful shape for a report.
Real output from the CLI, generated against a nested-barrel fixture, with no install required.
Open the sample report