Why File Splitting Still Matters (Even Though Tree-Shaking Doesn’t Depend on It)

1. The Myth: “Split Files or Die”
For years, a pervasive superstition has haunted the frontend ecosystem:
“You must put every export in its own file, or tree-shaking won’t work.”
This is technically false, but often architecturally useful. “Architecturally useful here means safer for humans, not required by bundlers.
Modern bundlers (Rollup, Webpack, esbuild) rely on static ESM analysis. They track variable usage across the import graph. If you export a and b from utils.ts, and the consumer only imports a, the bundler can prove that b is unused and remove it.
// utils.ts
export const a = () => console.log("I am used");
export const b = () => console.log("I am dead code"); // removed
No file splitting required.
So why do we still recommend splitting files?
Because the semantics of bundling often collide with the physics of development.
2. The Real Villain: Accidental Side Effects
The “one export per file” rule is not about enabling tree-shaking — it’s about protecting the bundler from human error.
A bundler’s prime directive is safety. If it cannot statically prove that importing a module has no side effects (mutating globals, registering polyfills, executing top-level logic), it must conservatively retain the entire module.
The Infection Vector
Consider a utility file:
// mixed-bag.ts
export const safe = () => {};
// ❌ the poison pill
import "./polyfill";
console.log("module loaded");
If a consumer imports { safe } from mixed-bag.ts, the bundler sees unavoidable top-level execution. It cannot safely drop the file.
Results:
- ❌ The module is retained
- ❌ All its top-level code executes
- ❌ Tree-shaking is blocked at the module boundary
The File-Splitting Defense
By splitting safe into its own file, you physically isolate it from side effects.
File splitting creates quarantine zones.
It forces side effects to be explicit imports, not accidental passengers.
3. The Hidden Benefit: Dev Server Physics
Beyond production bundles, file splitting has a large impact on the inner loop — the time between Ctrl+S and seeing results.
Hot Module Replacement (HMR)
Modern dev servers (Vite, Rsbuild) treat files as atomic update units.
- Monolithic file: Editing one line can invalidate a large module graph.
- Split files: Editing
string-utils.tsinvalidates only that small module.
The browser re-evaluates far less code, improving feedback time.
Cold Cache Behavior
Dev servers rely heavily on HTTP caching.
If your entire library funnels through index.ts, any change invalidates the cache for the whole graph. With split files, unchanged modules remain cached between reloads.
Splitting is an investment in developer velocity.
4. Nuance: When Bundling Everything Makes Sense
There are cases where bundling aggressively is the right choice.
CLIs and Binaries
For Node.js CLIs:
- Disk I/O matters
- Module resolution has cost
- Startup time dominates
Bundling into a single file reduces filesystem churn and improves startup latency.
Small, Single-Purpose Libraries
For very small libraries, the overhead of many tiny requests may outweigh the benefits. With HTTP/2 and HTTP/3 this argument is fading, but it still exists at the margins.
5. The Bundler Landscape
Different bundlers approach tree-shaking differently:
| Tool | Strategy | Notes |
| ----------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| Rollup | The Scientist | Deep, aggressive tree-shaking. Performs best when side effects are correctly declared. |
| esbuild | The Speedster | Conservative, fast analysis. Avoids speculative control-flow assumptions; benefits greatly from clean file boundaries. |
| Webpack | The Traditionalist | Relies heavily on sideEffects metadata. Without it, assumes modules may have side effects. |
6. Real-World tsup Architectures
tsup (powered by esbuild) can be configured for different distribution goals.
Library Mode (Consumer-Friendly)
For UI or utility libraries, preserve module boundaries so the consumer’s bundler can do optimal work.
// tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
target: "es2019",
// crucial for libraries
bundle: false,
clean: true,
});
Notes:
splittingis irrelevant here — it only applies when bundling.- Tree-shaking happens in the consumer’s build, not yours.
CLI Mode (Single Artifact)
For CLIs, optimize for startup speed and distribution simplicity.
// tsup.config.cli.ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/cli.ts"],
format: ["esm"],
target: "node18",
bundle: true,
minify: true,
clean: true,
});
7. The Missing Piece: sideEffects
Bundlers need help distinguishing safe modules from unsafe ones.
{
"sideEffects": false
}
This tells the bundler it can drop unused modules — except those explicitly listed (for example, CSS):
{
"sideEffects": ["**/*.css"]
}
This metadata is often more important than file layout.
8. The Final Verdict
We need to distinguish source organization from bundler behavior.
- Split source files for humans: clarity, isolation, and discipline.
- Configure outputs for machines: ESM, side-effect metadata, and letting consumers bundle.
File splitting does not make tree-shaking possible — it makes tree-shaking robust against mistakes.
Files are cheap. Safety is expensive. Optimize for safety.
Did you enjoy this post?
Give it a like to let me know!
