Unused JavaScript is like cancer — it kills slowly. It accumulates quietly over the course of years, release by release, and by the time anyone notices something is wrong the damage is already significant. No build error, no crash, everything seems to work — just a bundle that is silently 40% larger than it needs to be, a Time to Interactive that is consistently a second slower than it should be, and nobody knows why.
This article presents a bunch of ways how to find unused code, remove it, and configure tools and bundler to prevent dead code in the future. Sections for bundler are based on set of Vite, which under the hood delegates to Rollup in production.
Why unused JavaScript actually hurts
Every byte of JavaScript has a runtime cost. It is not just the download — the browser also has to parse and compile every line of code in your bundle before the page becomes interactive. You and at the end your customers pay that cost even for code that will never run.
It is not just a technical problem, bad practice that makes your code unreadable and hard to maintain, it is also real product issue affecting its whole lifecycle and causing:
- Slower initial load — users download JavaScript they will never execute
- Higher Time to Interactive — the browser engine compiles every byte before the page becomes responsive
- Unpredictable bundle sizes — without automated enforcement, dead code accumulates quietly across releases
Fortunately, this is not a process that cannot be prevented or reversed although the fix is not a one-time cleanup. It is a set of tools and bundler configuration that catches dead code automatically, at the point it is written and at build time.
Find unused code before the build
The best time to catch unused code is before it ever reaches the bundle. Different tools cover different scopes — from a single file up to the full project graph.
TypeScript compiler flags
The TypeScript configuration has a bunch of flags that turn unused code into compile-time errors — unused locals, unused parameters, unreachable statements. They operate at the file level, meaning the compiler checks each file individually in isolation from the others.
{
"compilerOptions": {
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowUnreachableCode": false,
"verbatimModuleSyntax": true,
"isolatedModules": true
}
}
Compiler will error on unused variables, function parameters, and unreachable code statements causing a build failure. The last two flags go a step further on the bundle side: verbatimModuleSyntax ensures type only imports are erased at compile time rather than pulled into the bundle as dependencies, and isolatedModules guarantees type exports are fully stripped at build time rather than emitted as real TypeScript references.
There is one miss - an issue the TypeScript compiler cannot detect. It sees individual files as standalone entities, not the project as a whole. A function can be exported and never imported anywhere but TypeScript will not warn about it. That is where knip comes in.
knip — whole-project unused export detection
knip is the tool for analysis the full project and detect exports that are never imported anywhere, files that are never referenced, and package.json dependencies that are never used in source code. It is the tool that closes the gap TypeScript leaves open — seeing only individual files instead of full project and cannot verify if an export is never consumed anywhere in the project.
A typical knip report looks like this. We can see that generated errors refer to the full project scope.
Unused exports (2)
src/utils/format.ts
formatCurrency line 14 ← exported but never imported anywhere
src/api/index.ts
LegacyApiClient line 45 ← exported but never imported anywhere
Unused files (1)
src/deprecated/old-connector.ts
Unused dependencies (1)
lodash ← in package.json but never imported in source
There is also a VS Code extension that allows for the same findings inline as you edit.
ESLint rules for dead code detection
Most projects already run ESLint. A couple of rules added to config can give instant feedback on dead code directly in the editor.
Just extending config file .eslintrc with few lines can help to catch a lot of unused entites:
{
"rules": {
// disabled — the TypeScript-aware version below replaces it to avoid duplicate warnings
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "warn",
// catches expressions whose result is computed but never used — dead code no-unused-vars misses
"@typescript-eslint/no-unused-expressions": "warn"
}
}
With these two rules, any unused variable, import, parameter, or dead expression gets underlined in the editor the moment it is written.
Configure tree-shaking
Tree-shaking is the bundler's automatic mechanism of dead code elimination at build time. Rollup — which Vite uses in production — traces every import/export from the entry point, marks everything reachable, and removes the rest - unused before producing the final bundle.
Tree-shaking is on by default in every vite build. It is not required to enable it. What you do need to do is just ensure the conditions it depends on are actually met — there are several ways they silently are not.
Declare "sideEffects" in package.json
A side effect is any code that runs the moment a file is imported, regardless of whether any export is used:
// ❌ side effect — runs on import even if nothing from this file is used
window.__appVersion = "1.0.0";
// ✅ no side effects — nothing runs on import, only exports are defined
export const formatPrice = (n: number) => `£${n.toFixed(2)}`;
So in many cases these files are not used, can be just some unintended remainders. When Rollup finds such a side effect, it wants to drop it from bundle — but it cannot do that blindly. It assumes the worst and keeps every module, which makes tree-shaking ineffective. The way to enforce Rollup to drop any side effects is to set property"sideEffects" in package.json to false. It is the information for Rollup to remove side effects when finding them.
{
"sideEffects": false
}
The field accepts three forms:
// Every module is safe to drop if unused
{ "sideEffects": false }
// Only these specific files have side effects
{ "sideEffects": ["./src/polyfills.ts", "*.css"] }
// Field omitted — Rollup keeps all modules regardless of usage
{}
For most projects false is the correct value. The most common real exception is CSS. A CSS import such as import './styles.css' has no exports — it works entirely as a side effect by injecting styles into the DOM. Setting "sideEffects": false globally would cause Rollup to silently discard all CSS imports. List affected files explicitly instead:
{
"sideEffects": ["*.css", "*.scss"]
}
Ensure ES module output from shared packages
Tree-shaking strongly depends on static import/export syntax — it is the only syntax that gives Rollup enough information to determine what is actually used. CommonJS (require/module.exports) is resolved at runtime, so Rollup cannot analyse it statically and takes conservative approach for keeping the entire module. It is very important for shared packages - if they only ships a CommonJS build, tree-shaking is silently disabled for it in every consuming app, regardless of how those apps are configured.
In the package you publish, declare an ES module entry point alongside the CommonJS one:
// ❌ CommonJS only — Rollup cannot tree-shake this
{
"main": "./dist/index.js"
}
// ✅ Both outputs declared — Rollup picks the ESM entry
{
"main": "./dist/index.cjs.js",
"module": "./dist/index.esm.js",
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js"
}
}
}
The paths must match exactly what your build produces. Run yarn build, inspect the output in dist/, and copy those exact paths into package.json. A path pointing to a non-existent file causes Rollup to fall back to "main" and as a result tree-shaking for that package is disabled without any indication.
Anti-patterns that silently defeat tree-shaking
Even with correct configuration, certain code patterns prevent Rollup from eliminating unused modules.
Barrel files with export *
// shared/index.ts
export * from "./format-utils"; // 12 utility functions
export * from "./date-utils"; // 8 utility functions
export * from "./api-client"; // large HTTP client
export * from "./constants"; // configuration constants
When a consumer writes import { formatPrice } from './shared', Rollup encounters the wildcard re-export and cannot statically determine which exports from each module will be used in practice. For safety it pulls in all modules even this never referenced.
Replace wildcard re-exports with named ones so Rollup can trace the dependency graph precisely:
// ✅ Rollup can trace exactly what is available and what is used
export { formatPrice, formatCurrency } from "./format-utils";
export { parseDate, formatDate } from "./date-utils";
export { HttpClient } from "./api-client";
export { DEFAULT_TIMEOUT } from "./constants";
Or skip the barrel entirely and import directly from the source file:
// ✅ No barrel, no ambiguity
import { formatPrice } from "@your-scope/utils/format-utils";
require() in shared package source
// ❌ A single require() forces CommonJS treatment for the entire file
const utils = require("./utils");
A single require() call anywhere in a file causes Rollup to treat it as CommonJS and skip tree-shaking it entirely. All source files in published shared packages must use import/export exclusively — mixing module formats in the same file is not supported.
Verify the production bundle
After making changes, you need to confirm they worked. Build output printed to the terminal gives you chunk sizes, but rollup-plugin-visualizer gives you the full picture.
rollup-plugin-visualizer is one of the most widely used bundle analysis tools in the Rollup and Vite ecosystem — well-maintained, with a large community. It works as a Vite plugin (Vite delegates to Rollup in production, so the integration is seamless) and adds no overhead to normal builds.
After an analysis build it opens an interactive html report in the browser automatically. The report renders the entire bundle as a treemap — every module represented as a rectangle, sized by its byte weight, grouped by chunk and package. The result makes clearly visible which packages dominate the bundle, what actually changed after an optimisation, and what is still there that probably should not be.
It also supports multiple visualisation modes — treemap, sunburst, network graph, and flamegraph — each suited to a different diagnostic question, from understanding what is taking up space to tracing why a specific module ended up in the bundle at all.
Putting it together
None of these steps on their own are enough. TypeScript flags unused locals, but misses unused exports. knip catches unused exports, but only runs when you invoke it. The bundler eliminates dead code at build time, but only if you have set up "sideEffects" and ES module output correctly.
Set them all up once. After that, unused code gets caught automatically — at the point it is written, in CI, and at build time. The bundle stays small not because someone remembered to clean it up, but because the tooling makes shipping dead code actively difficult.
Top comments (0)