How to Identify and Eliminate Dead Code at Scale
How to Identify and Eliminate Dead Code at Scale
Every codebase I've audited has dead code. Every single one. The question is how much, and whether it's costing you anything.
At my last company, we discovered that 18% of our TypeScript codebase was dead. Not "rarely used." Dead. Functions never called. Components never rendered. Types never referenced. Entire modules that were imported by nothing. We'd been maintaining, reviewing, and testing code that could have been deleted without any user noticing.
The cost wasn't just disk space. Dead code creates real problems:
- Developers waste time reading and understanding code that doesn't matter
- Dead code shows up in search results, making it harder to find the code that does matter
- Tests for dead code run in CI, slowing down every build
- Dead code can mask real bugs by making it seem like a feature is implemented when the implementation is actually orphaned
We deleted 47,000 lines over two weeks. CI time dropped by 4 minutes. New developer onboarding got measurably faster. And nobody noticed anything was missing.
What Counts as Dead Code
Dead code falls into several categories:
1. Unreachable code: Code that can never execute because a condition is always false or a return statement precedes it.
function process(input: string) {
if (typeof input !== 'string') {
// TypeScript guarantees input is string. This block is dead.
throw new Error('Invalid input');
}
return input.toUpperCase();
}2. Unused exports: Functions, classes, or types that are exported but never imported anywhere.
3. Unused dependencies: npm packages in your package.json that aren't imported by any file.
4. Feature flags left on/off: Code behind a feature flag that was permanently enabled or disabled months ago but never cleaned up.
5. Orphaned modules: Entire files or directories that aren't reachable from any entry point.
6. Dead CSS: Selectors that don't match any element in your application.
Detection: Tools and Techniques
For Unreachable Code
TypeScript's compiler catches some cases with noUnusedLocals and noUnusedParameters. ESLint's no-unreachable rule catches code after return statements.
But these only catch the obvious cases. For deeper analysis:
# ts-prune: Find unused exports in TypeScript
npx ts-prune | grep -v '(used in module)'ts-prune analyzes your entire TypeScript project and reports every exported symbol that isn't imported anywhere. It's not perfect (dynamic imports and some patterns fool it), but it catches 80-90% of unused exports.
For Unused Dependencies
# depcheck: Find unused npm packages
npx depcheck
# knip: More thorough, handles workspaces
npx knipKnip is my current recommendation. It finds unused dependencies, unused exports, unused files, and unused types in a single pass. It handles monorepos and workspaces, and its configuration is straightforward.
For Orphaned Modules
# Find files not imported by anything (using knip)
npx knip --include files
# Or with a custom approach: find all .ts files not referenced by any other .ts file
for file in $(find src -name "*.ts" -not -name "*.test.ts" -not -name "*.d.ts"); do
basename=$(echo "$file" | sed 's/\.ts$//' | sed 's/.*\///')
count=$(grep -r "$basename" src --include="*.ts" -l | grep -v "$file" | wc -l)
if [ "$count" -eq 0 ]; then
echo "ORPHAN: $file"
fi
doneFor Dead CSS
Tools like PurgeCSS (used in Tailwind's build process) and UnCSS can identify CSS selectors that don't match any HTML element. In component-based frameworks, this is trickier because components are rendered dynamically. Chrome DevTools' Coverage tab gives you runtime coverage data.
Elimination Strategy: The CLEAN Method
Deleting code is more dangerous than writing it. A wrong deletion causes a production outage. So here's the methodical approach I use:
C - Catalog: Run your detection tools and produce a list of candidates. Don't delete anything yet. Just build the list.
L - Liveness check: For each candidate, verify it's truly dead. Some techniques to double-check:
- Search for dynamic references (string-based imports, reflection,
eval) - Check if the code is referenced in configuration files, build scripts, or documentation
- Look for cases where the code is used in tests but not production (the tests should be deleted too)
- For API endpoints, check access logs to confirm nobody's calling them
E - Evaluate risk: Categorize each candidate by risk:
- Low risk: Unused utility functions, orphaned test files, unused types
- Medium risk: Unused exports from shared libraries (might be used by external consumers)
- High risk: Unused API endpoints (might have external callers), code behind feature flags (flag might be controlled externally)
A - Atomic removal: Delete dead code in small, focused PRs. One module per PR. This makes it easy to revert if something breaks. Don't bundle 47,000 lines of deletions into a single PR. That's how you spend a weekend debugging a rollback.
N - Notify and wait: After each deletion PR ships to production, wait 48 hours before moving to the next one. Monitor error rates and logs. If something breaks, the small PR scope makes it obvious what caused the issue.
Prevention: Stopping Dead Code From Accumulating
Deleting dead code is a one-time cleanup. Preventing it from accumulating is the ongoing discipline.
1. Make unused code visible: Add a CI check that runs ts-prune or knip on every PR. Don't block merges (yet), but log the results. When developers see "3 new unused exports introduced" in their PR checks, they'll clean up before merging.
2. Feature flag hygiene: Establish a rule: every feature flag has an expiration date. When the flag is permanently on or off for more than 30 days, a task is automatically created to clean up the branching code.
3. Dependency auditing: Run npx knip monthly. Remove unused packages. This is low-risk and high-reward.
4. Entry point analysis: Periodically trace your application's entry points (routes, exports, event handlers) and verify that every module is reachable from at least one entry point. Tools like Webpack's tree-shaking analysis can help here.
The Contrarian Take: Some Dead Code Should Stay Dead (Not Deleted)
Not all dead code should be deleted. Here's when I leave it alone:
- Code that's dead because of a feature flag that might be re-enabled: Don't delete it. Annotate it with a comment explaining the flag.
- Code that serves as documentation: Sometimes a commented-out implementation shows a previous approach that was tried and rejected. The comment is more valuable than a git history entry.
- Code in shared libraries with external consumers: If you publish an npm package and an export appears unused, it might be used by consumers you can't scan.
The rule: delete dead code when you're confident it's dead. Leave it when you're not. But always annotate the "intentionally kept" code so the next person doesn't waste time investigating.
The Numbers
In my experience, a typical production codebase has:
- 10-20% dead code by line count
- 5-15% unused npm dependencies
- 2-8% orphaned files (files not imported by anything)
Cleaning this up reliably delivers:
- 5-15% faster CI builds
- Measurably faster IDE responsiveness (fewer files to index)
- Reduced cognitive load for new team members
- Fewer "wait, is this used?" conversations in code review
It's not glamorous work. Nobody gets promoted for deleting code. But the cumulative effect on team velocity is real and measurable. Find the dead weight. Cut it. Move faster.
$ ls ./related
Explore by topic