DEV Community

maissenayed
maissenayed

Posted on • Originally published at maissenayed.substack.com

The Barrel Trap: How I Learned to Stop Re‑Exporting and Love Explicit Imports

I was happily building out a small UI library. Everything was neat, clean, and centralized — I had this beautiful index.ts that re‑exported all my components. It felt elegant:

// src/index.ts
export * from “./Button”;
export * from “./Input”;
export * from “./Form”;
Enter fullscreen mode Exit fullscreen mode

Then, one day, a user sent me a message:

“Hey, importing just Button seems to pull in the entire library. My bundle exploded.”

I laughed it off at first. Surely tree‑shaking would take care of that.

But when I checked the build size myself, my laughter turned into that slow developer squint of dread. I’d fallen into the barrel trap.

Step 0: Just Export Everything

It starts innocently. You think you’re making life easier:

import { Button, Input } from “@important-lib”;
Enter fullscreen mode Exit fullscreen mode

It’s clean! Users love it. The DX feels fantastic.

Until one day, you try to optimize something — and everything slows down. TypeScript starts crawling. Your IDE fans spin up. You import one thing, and half your repo joins the party.

What happened?

Step 1: Find a Repro

Every performance bug starts with denial. I thought, this can’t be the barrel’s fault. So I tried to measure it.

I created two test files:

// app/barrel-import.ts
import { Button } from “@important-lib”;

// app/direct-import.ts
import { Button } from “@important-lib/button”;
Enter fullscreen mode Exit fullscreen mode

Then bundled both with Vite:

npx vite build
Enter fullscreen mode Exit fullscreen mode

And finally, compared sizes:

ls -lh dist/assets/*.js dist/*.js dist/chunks/*.js
Enter fullscreen mode Exit fullscreen mode

Barrel version: 210 KB.
Direct version: 47 KB.

The barrel was importing everything — Input, Form, Modal, Table, you‑name‑it.
My IDE lag wasn’t a coincidence; it was TypeScript resolving every symbol I ever exported.

That was my scroll jitter moment.

Step 2: Narrow the Repro

The problem was clear but broad. Was it Rollup’s fault? TypeScript? My export style?
To isolate it, I commented out just one line at a time in index.ts.

// export * from “./Input”;
// export * from “./Form”;
export * from “./Button”;
Enter fullscreen mode Exit fullscreen mode

Each deletion shrank the bundle size.

At some point, I realized that even with no side effects, just the re‑export itself forces TypeScript and bundlers to crawl dependency graphs to merge type declarations and resolve names.

It’s like your barrel whispers: “I don’t know what’s inside these modules, but please go find out, every single time.”

So yeah, my problem wasn’t React Router — it was me.

Step 3: Remove Everything Else
At this point, I decided to take my own medicine.
I started deleting code — not randomly, but with a rule:

After each change, I must still reproduce the bloat.

One by one, I removed:

components

re‑exports

export * wildcards

lazy imports

Every time, I checked the bundle. Eventually, I ended up with one clean entry point that re‑exported only three components. The bundle dropped to 60 KB.

The bug — or more precisely, the hidden cost — was gone.

It wasn’t the toolchain’s fault. It was my pattern’s.

Step 4: Find the Root Cause

Here’s what I learned.

Barrels are fine in apps because the app owns its graph. It’s one build. You know what’s included. You don’t care about tree‑shaking that much.

Barrels are a mess in libraries because they:

  • confuse tree‑shaking (export * = black box)
  • slow type analysis
  • hide dependency coupling
  • flatten your API in ways that make versioning risky

The library becomes one giant namespace where every import knows about every other import. Even if you only ship one component, your users pay for all of them in type time or bundle size.

Step 5: Build a Better Export Map

Here’s how I fixed it with explicit exports:

{
  “name”: “@important-lib”,
  “type”: “module”,
  “exports”: {
    “.”: {
      “types”: “./dist/index.d.ts”,
      “import”: “./dist/index.js”
    },
    “./button”: {
      “types”: “./dist/button.d.ts”,
      “import”: “./dist/button.js”
    },
    “./input”: {
      “types”: “./dist/input.d.ts”,
      “import”: “./dist/input.js”
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now users can do:

import { Button } from “@important-lib/button”;
Enter fullscreen mode Exit fullscreen mode

and only get what they actually use.

Tree‑shaking started working again. Type checking became instant. The IDE stopped gasping for air.

Step 6: Try It Yourself (Vite Edition)

If you want to benchmark your own library, here’s your repro template with Vite:

Create a minimal consumer app.

npm create vite@latest barrel-repro -- --template react-ts
cd barrel-repro
npm i
Enter fullscreen mode Exit fullscreen mode

Add two entry files.

// app/barrel-import.ts
import { Button } from “@important-lib”;

// app/direct-import.ts
import { Button } from “@important-lib/button”;
Enter fullscreen mode Exit fullscreen mode

Tell Vite to build both entries.
Create vite.config.ts:

import { defineConfig } from “vite”;
import react from “@vitejs/plugin-react”;
import { resolve } from “path”;

export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      input: {
        barrel: resolve(__dirname, “app/barrel-import.ts”),
        direct: resolve(__dirname, “app/direct-import.ts”),
      },
      output: {
        // keep names stable for easier diffing
        entryFileNames: “[name].js”,
        chunkFileNames: “chunks/[name]-[hash].js”,
        assetFileNames: “assets/[name]-[hash][extname]”,
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Build and compare sizes.


npx vite build
ls -lh dist/*.js dist/chunks/*.js
Enter fullscreen mode Exit fullscreen mode

If your barrel.js pulls in many more chunks (or is much larger) than direct.js, you’ve got your reproducible proof.

Now start deleting exports until you can’t make it any smaller.

🎉 Bonus: Name Your Chunk in Vite to Spy on Bloat
Sometimes the bundle analyzer is overkill. You just want to force a suspicious dependency into a named chunk so it’s obvious when it shows up. Vite lets you do this via Rollup’s manualChunks.

Add this inside vite.config.ts → build.rollupOptions.output:

output: {
  entryFileNames: “[name].js”,
  chunkFileNames: “chunks/[name]-[hash].js”,
  assetFileNames: “assets/[name]-[hash][extname]”,
  manualChunks(id) {
    // Anything coming from @important-lib becomes its own chunk
    if (id.includes(”@important-lib”)) return “important-lib”;

    // Optionally split all node_modules into vendor
    if (id.includes(”node_modules”)) return “vendor”;
  },
}
Enter fullscreen mode Exit fullscreen mode

Now, when you build:

npx vite build
Enter fullscreen mode Exit fullscreen mode

You’ll see a clear chunks/important-lib-*.js. Import from the barrel and see if it drags important-lib in; switch to subpath imports and verify that the chunk disappears (or gets much smaller). It’s a dead-simple, visual lie detector for barrels.

Step 7: The Bigger Lesson

Every barrel starts as a convenience and ends as an obligation.
It’s not that barrels are evil — it’s that they hide the edges of your system.

Once you lose edges, you lose insight.

The cure is the same as debugging anything complex: isolate, measure, remove, repeat.

That’s how you fix any bug — even the ones you wrote on purpose.

Top comments (0)