DEV Community

Христо Тодоров
Христо Тодоров

Posted on

Where does your startup time actually go?

A visual look at why index.ts re-export files are slow — with real flame graphs.


You've written this file. Everyone has:

// icons/index.ts
export * from './Activity'
export * from './Airplay'
export * from './Anchor'
// ...and 12 more
Enter fullscreen mode Exit fullscreen mode

It's the barrel file. One tidy entry point so consumers can write the clean version:

import { Activity } from './icons'
Enter fullscreen mode Exit fullscreen mode

instead of the noisy version:

import { Activity } from './icons/Activity'
Enter fullscreen mode Exit fullscreen mode

Looks harmless. It is not. That one import just loaded every module in the folder — and I can show you, frame by frame.


The setup

A folder of 15 icon modules, each importing a small shared base and doing a little work at load (parse, set up, register — the stuff every real module does):

icons/
  base.js
  Activity.js   Airplay.js   Anchor.js   ...   Briefcase.js   (15 of them)
  index.js      ← the barrel: re-exports all 15
Enter fullscreen mode Exit fullscreen mode

Two ways to grab a single icon:

// barrel/main.js
import { Activity } from './icons/index.js'      // through the barrel

// direct/main.js
import { Activity } from './icons/Activity.js'   // straight to the file
Enter fullscreen mode Exit fullscreen mode

Both give you the exact same Activity. I profiled each with loadometer, a tiny tool that times every module your app loads and prints a flame graph:

node --import loadometer/register main.js
Enter fullscreen mode Exit fullscreen mode

The result

import modules loaded load time
./icons (barrel) 18 (the barrel + 15 icons + base) 95 ms
./icons/Activity.js (direct) 3 (just Activity + base) 9 ms

Same icon. ~10× the work, for importing one thing instead of fifteen.

Through the barrel — every icon lights up, even though we used one:

Direct — one sliver:

The flame graph makes the cost obvious in a way a number never does: the barrel is as wide as the entire folder, because importing it is importing the entire folder.

Why it happens

A module's job, when loaded, is to run its top-level code. The barrel's top-level code is "load and re-export all 15 icons." So the runtime has no choice — to give you Activity, it must first evaluate the whole barrel, which means evaluating every module the barrel touches. Your Activity is at the bottom of a 95ms pile you never asked for.

This is true for require and for import. It's not a bug — it's just what "re-export everything" means at runtime.

Devil's advocate: "but I use all of them anyway"

Fair challenge — so I measured it. Same demo, but importing all 15 icons both ways (import * as icons from './icons' vs. 15 explicit imports):

you import barrel direct
one icon ~94 ms ~9 ms
all 15 icons ~94 ms ~94 ms

When you genuinely use the whole folder, the barrel is not slower — both load all 15 modules, so it's a wash (the barrel just adds one tiny extra file: the index itself). Barrels aren't inherently slow, and "never use a barrel" is the wrong takeaway.

But read that table the other way. The barrel makes "use one" cost exactly as much as "use all." Direct imports scale with what you touch — 9 ms for one, 94 ms for fifteen. The barrel charges the full 94 ms no matter what, even when you only wanted a single icon. It removes your ability to pay for only what you use.

So the rule isn't "barrels bad." It's: a barrel is fine when consumers use most of it, and a tax everywhere it's used as a convenient front door for a slice — which, for a 200-icon set or a utils/ grab-bag, is almost always.

"But tree-shaking fixes this"

Sometimes. A good bundler in a production build can drop the unused re-exports — if every module is side-effect-free and the graph cooperates. In practice it often doesn't, and plenty of code never goes through that optimization at all:

  • Dev server / HMR — unbundled, every barrel is paid in full, on every restart.
  • SSR & serverless — Node loads modules directly; a fat barrel is a fatter cold start.
  • Tests — importing one helper through a barrel drags the whole folder into every test file.
  • CLIs and scripts — no bundler, just node.
  • Side-effectful or circular barrels — tree-shaking quietly gives up and keeps everything.

The runtime cost is real wherever the bundler isn't — which is most of where your code actually runs while you're building it.

Find your own barrels

You don't have to guess. Point loadometer at your entry and look for a frame that's suspiciously wide for how little you used it:

# prints folded stacks to a file…
LOADOMETER_OUT_FILE=imports.folded node --import loadometer/register app.js
# …drop it on https://speedscope.app for the flame graph
Enter fullscreen mode Exit fullscreen mode

If a single index frame spans half the chart, that's your barrel.

The fixes (cheapest first)

  1. Import from the file, not the barrel. from './icons/Activity', not from './icons'. Boring, free, works everywhere.
  2. Stop re-exporting side effects. If index.ts exists only to save keystrokes, it may be costing more than it saves.
  3. Make barrels narrow. Group by what's actually imported together, not "everything in the folder."
  4. Lint it. Rules like no-barrel-files / import/no-namespace, or biome's import rules, catch new ones before they grow.
  5. Lazy-load the heavy stuff with dynamic import() so it's off the startup path entirely.

The takeaway

Barrel files optimize for the writer (one tidy import line) at the expense of the runtime (loading a whole folder to use one file). The convenience is real, and so is the bill — it's just invisible until you put a flame graph next to it.

So put a flame graph next to it.


Profiled with loadometernpm i -D loadometer, then node --import loadometer/register app.js. It works with require and import, on Node and Bun.

Top comments (0)