Engineering
What dead code actually costs, and why it is hard to delete
Unused code is not a performance problem in most systems — it never runs. The cost lands somewhere less obvious: on every engineer who has to read around it, and on every build that carries it.
Four costs, only one of which is obvious
When teams justify a cleanup, they usually reach for build times. That is real but it is rarely the largest cost.
| Cost | Who pays it | When you notice |
|---|---|---|
| Comprehension | Every engineer reading the module | Continuously, invisibly |
| Build and test time | Everyone, every commit | When CI gets slow enough to complain about |
| False dependency surface | Whoever upgrades a library | During a migration that turns out larger than scoped |
| Security review | Whoever answers the questionnaire | At audit, or after a CVE |
The comprehension cost is the one that compounds. An engineer reading a file cannot tell unused code from load-bearing code by looking. So they read it, reason about it, and preserve its behaviour during changes — spending real effort on something that does nothing.
The dependency cost is the one that surprises people. A library kept only because dead code imports it still appears in your dependency tree, still needs upgrading, and still shows up in vulnerability scans. Deleting the caller often deletes the dependency, which is why cleanups sometimes shrink a migration dramatically.
Why it is genuinely hard to delete
"Unused" is easy to say and hard to prove. Static analysis finds code with no reachable callers, which is necessary but not sufficient, because several things call code without a visible reference:
- Reflection and dynamic dispatch — a handler resolved from a string at runtime.
- Dependency injection and framework conventions — a class registered by annotation or naming, never imported directly.
- Serialisation — a field unused in code but present in stored data or an API contract.
- Public API surface — nothing in your repository calls it, but a consumer does.
- Feature flags — code that is off today and expected back next quarter.
This is why a scanner should be treated as a candidate list, not a delete list. The distinction matters: a tool that reports possibly-dead code is useful; a tool trusted to delete it unattended will eventually remove something reached by reflection, and the failure will appear far from the change.
How we delete safely
- Start where the blast radius is smallest. Internal helpers and private functions before anything on a public boundary.
- Deprecate with telemetry before deleting. If you can log a call, ship that first and wait. Silence over a full release cycle is evidence; static analysis alone is inference.
- Delete in small, single-purpose commits. A revert should undo one removal, not unpick a cleanup from a refactor.
- Delete the dependency too. If removing the last caller frees a library, remove the library in the same change. That is where the real win usually is.
- Check the excluded list. Anything skipped for reflection, DI or serialisation reasons should be written down, or the next person will rediscover it.
How to tell it worked
Measure before and after on your own codebase rather than trusting a general claim: CI wall-clock time, lines under maintenance, count of direct dependencies, and open vulnerability findings. Those four move for real reasons and are hard to argue with. Anything more precise depends on your stack, and a figure quoted from someone else's repository tells you nothing about yours.
Written and reviewed by the CodeDetox team. This describes our own engineering practice and reasoning. Effects on build time, bundle size and review load depend heavily on your codebase and toolchain, so we describe mechanisms rather than quoting figures we cannot support for your project.