Beyond Linting: Advanced Dead Code Detection in a Monolith
Tired of code graveyards in your monolith? Go beyond basic linting with advanced static and runtime analysis to safely detect and remove dead code for good.
Alex Miller
Principal Engineer focused on developer experience, code quality, and large-scale refactoring.
We’ve all been there. You parachute into a mature monolithic codebase, a sprawling digital city built over years by countless developers. Your task is simple: add a new feature. But as you navigate the labyrinth of directories and files, you encounter strange, dusty corners—functions and classes that seem completely disconnected from everything else. Are they critical infrastructure or forgotten relics? Nobody knows. The git history is an enigma, and the original authors are long gone.
Welcome to digital archaeology. Your linter, dutifully flagging unused variables within a file, is like a metal detector that can only find loose change on the surface. It’s helpful, but it won’t find the buried treasure—or in this case, the buried junk. To truly clean up a monolith, we need to go beyond basic linting and embrace more sophisticated techniques for detecting dead code.
The Blind Spots of Basic Tooling
Standard tools like ESLint’s no-unused-vars
or your IDE’s built-in analysis are fantastic for local hygiene. They keep individual files clean. But in a large, interconnected system, they fall short. Why? Because they lack the global context of the entire application. They can’t see the forest for the trees.
Here are just a few ways dead code can hide in plain sight:
- Dynamic Imports & Reflection: Code that is loaded based on a string variable, a database value, or a configuration file. A static analyzer looking for direct
import
orrequire
statements will miss these entirely. Think of plugin systems or strategy patterns implemented with dynamic lookups. - Dependency Injection (DI) Containers: A class might appear un-instantiated, but a DI container could be constructing it at runtime based on type annotations or string keys.
- Feature-Flagged Code: A whole feature might be wrapped in a feature flag that has been turned off for years. The code is still referenced, so it’s not technically unreachable, but it’s functionally dead.
- API Endpoints: An old API endpoint might still be wired up in the router, but no client has called it in two years. The code is live, but unused.
Relying on simple tools in these scenarios is a recipe for disaster. You might delete a critical piece of code that’s only invoked during a year-end financial report, or you might leave mountains of obsolete code sitting around, adding to the cognitive load for every developer who follows.
A Multi-Pronged Strategy for True Detection
There is no single magic bullet. The most effective approach combines deep static analysis with real-world runtime data. One tells you what’s possible, and the other tells you what’s actually happening.
Going Deeper with Static Analysis
The first step is to level up your static analysis game. Tools like ts-prune (for TypeScript) or depcheck are a good start. They scan your entire project to find exported-but-unimported files and unused dependencies, which is a huge step up from single-file linting.
However, even these tools can be fooled by the dynamic patterns we mentioned earlier. The ultimate goal of advanced static analysis is to build a complete, project-wide dependency graph. This involves parsing every file into an Abstract Syntax Tree (AST) and tracing every function call, variable reference, and class instantiation from the application’s entry points (e.g., your main server file, API routes, cron jobs).
While building a custom tool to do this is a significant undertaking, understanding the principle is key. You’re looking for code that is provably unreachable from any known entry point. This gives you a high-confidence list of candidates for deletion.
Runtime Analysis: The Ground Truth
This is where things get really powerful. Static analysis tells you what should happen, but runtime analysis tells you what is happening in your production environment. The concept is simple: if code hasn’t been executed in production for a significant period, it’s a strong candidate for being dead.
How do you get this data? Code coverage!
We typically think of coverage in the context of testing, but we can apply the same instrumentation to our production builds. Here’s a high-level approach:
- Instrument Your Code: Use a tool like Istanbul.js to instrument your code before deploying. This adds tracking counters to every function, statement, and branch without significantly impacting performance.
- Collect Coverage Data: Set up an endpoint on your server that can periodically collect this coverage data from your running application instances and aggregate it in a central location (like an S3 bucket or a database).
- Analyze Over Time: Let this run for an extended period—weeks, or even months. You want to capture monthly and quarterly cycles. After a while, you can generate a coverage report for your entire monolith based on actual production usage.
The resulting report is a goldmine. Files or functions with 0% coverage are empirically unused. They aren’t just theoretically dead; they are practically dead.
Combining Static and Runtime Data
The magic happens when you cross-reference your static analysis candidates with your runtime usage data. This allows you to classify your findings and act with more confidence.
Static Analysis Result | Runtime Result (Production) | Conclusion & Action |
---|---|---|
Unreferenced | Not Executed (0% coverage) | Highest confidence. This is definitively dead code. Safe to delete after verification. |
Referenced | Not Executed (0% coverage) | Likely dead. The code is part of a dead feature, an old A/B test, or behind a permanently-off feature flag. Investigate the call site before removing. |
Unreferenced | Executed | Warning! Your static analyzer has a blind spot. This code is being called dynamically. Do not delete. Instead, figure out how it's being called and see if you can make it statically analyzable. |
The Human Element: A Phased and Safe Removal Process
Even with the best data in the world, you can’t just run rm -rf
on everything your tools flag. A careful, human-driven process is essential to prevent accidents.
- Identify & Log: Start with your highest-confidence candidates (statically unreferenced and not executed in production). Instead of deleting the code immediately, add a loud log statement.
logger.warn("DEPRECATED_CODE_EXECUTED: myFunction was called. Please investigate.")
. Deploy this change. - Deprecate & Communicate: Mark the function or class with
@deprecated
comments, explaining why it's considered dead and linking to the analysis ticket. Announce the deprecation to your team. - Monitor: Wait for at least one full release cycle (or longer, depending on your business). Watch your logs and monitoring dashboards. Did the warning ever fire? If it did, you just saved yourself from an incident. Go back and investigate.
- Remove: If the logs remain silent and no one on the team objects, you can now confidently open a pull request to delete the code. The PR description should link to all your prior research, making it easy to approve.
Reclaiming Your Codebase
Tackling dead code in a monolith isn’t a one-time cleanup; it’s a cultural shift. It’s about establishing a process that combines powerful tooling with careful, data-informed human judgment. By moving beyond basic linting and embracing a hybrid static/runtime analysis approach, you can systematically and safely chip away at years of technical debt.
The benefits are immense: faster builds, a smaller application bundle, reduced cognitive load for developers, easier onboarding, and—most importantly—the confidence to make changes without fear of breaking some long-forgotten, critical piece of the system. It’s time to put down the archaeologist’s brush and pick up the sculptor’s chisel. Your codebase will thank you for it.