We Deleted 30% of the Codebase and Nothing Broke

A client's team was drowning in a 200K-line TypeScript monolith. When we finally measured what was actually running in production, we found that almost a third of it was dead code nobody had touched in over a year.


The onboarding document was 47 pages long. I know because I read it twice, and the second time I started highlighting things that didn't match reality. On page 23, there was a section about the "dynamic pricing engine." Four paragraphs explaining the architecture, a diagram of how it interacted with the recommendation service, and a note that said "see /src/pricing/ for implementation details."

The pricing engine had been turned off eighteen months ago. The code was still there. So was the documentation. So were the tests — 34 of them, running in CI on every single pull request, adding about 90 seconds to a pipeline that developers already complained was too slow.

I was two weeks into an engagement with a mid-size B2B SaaS company. Fourteen engineers, a TypeScript monolith sitting at roughly 210,000 lines of code, and a team that described their own codebase as "haunted." Their word, not mine. Too many rooms, too many dark corners, and nobody quite sure what lived where.

The symptom wasn't performance. It was fear.

The team's velocity had been declining for three quarters straight. Not dramatically — just a slow, consistent drag. Features that the tech lead estimated at five days were routinely taking eight or nine. When I asked developers what was slowing them down, I expected the usual answers: unclear requirements, too many meetings, flaky tests.

Instead I kept hearing variations of the same thing: "I'm afraid to change that file." One engineer told me she spent an entire afternoon tracing call chains to confirm that a function she wanted to modify wasn't being used by something she didn't know about. It was. But the thing using it was itself dead — a webhook handler for an integration the company had discontinued a year prior.

That's when I started counting.

Measuring the graveyard

We took two approaches. The first was static analysis — straightforward enough. We ran ts-prune across the codebase and got an initial list of unexported, unreferenced symbols. That caught the obvious stuff: utility functions nobody called, type definitions for API responses from services that no longer existed, entire modules behind import paths that appeared nowhere in the dependency graph.

The second approach was more revealing. We instrumented production for two weeks, logging which routes were actually hit and which internal modules were loaded at runtime. This is the part that made people uncomfortable, because static analysis can tell you what could be called. Production telemetry tells you what is being called. The gap between those two was enormous.

Note

Static analysis found about 18% of the code was unreferenced. Production telemetry pushed that number to 31%. The difference was code that was technically reachable but lived behind feature flags that were permanently off, API endpoints that hadn't received a request in months, and admin tools that had been replaced by third-party services.

Here's what the breakdown looked like:

  • Fully dead code — no references, no imports, no routes: 38,000 lines
  • Zombie code — reachable in theory, never executed in production: 27,000 lines
  • Vestigial tests — tests for dead or zombie code: 8,400 lines across 112 test files

That's 73,400 lines. In a 210K-line codebase.

The hard part wasn't finding it. It was deleting it.

I expected the team to be relieved. Some were. But two senior engineers pushed back hard. One of them had written the pricing engine. "What if we bring it back?" he asked. I pointed out that the product team had no plans to reintroduce dynamic pricing, and that even if they did, the old implementation was built against an API version that the vendor had since deprecated. Bringing it back would mean rewriting it anyway.

The other concern was more pragmatic: "How do we know the telemetry didn't miss something?" Fair question. Some code paths only trigger on the first of the month, or during annual reconciliation, or when a specific error condition occurs. We extended our observation window for anything that looked like it might be time-dependent. We cross-referenced with cron schedules and event triggers. A few things came back from the dead — a quarterly reporting module and a webhook for a partner that only sent data on Mondays. Those stayed.

Everything else went through a three-step process. First, we'd comment out the code and deploy to staging for a week. Then we'd add runtime logging that would fire if the code path was somehow reached, and deploy that to production. After another week of silence, we deleted it for real.

It was tedious. It was also the most impactful refactoring work I've done in years.

What changed

The CI pipeline dropped from 14 minutes to 9. Not just because of fewer tests — the TypeScript compiler was doing less work, the bundler was processing fewer modules, and the test runner wasn't spinning up database fixtures for features that didn't exist anymore.

But the real difference was cognitive. Developers stopped tiptoeing. The engineer who'd spent an afternoon tracing phantom call chains told me two weeks after the cleanup: "I actually understand this codebase now. I can hold the shape of it in my head." Onboarding a new developer went from three weeks of "don't touch that, we're not sure what it does" to something more like ten days of focused ramp-up.

The team's estimation accuracy improved too, though I'm cautious about attributing that solely to the cleanup. When you remove 30% of the code, you remove 30% of the dark corners that make estimates feel like guesses.

Why this keeps happening

Every codebase I've worked on has some dead code. The question is how much. The pattern is always the same: a feature gets deprecated, but nobody deletes the implementation because deletion requires confidence that nothing depends on it, and building that confidence takes effort. So the code stays. Then someone writes new code nearby and works around the dead code without realizing it's dead. Now the dead code has shaped the living code. The cost compounds.

Most teams don't have a process for removing code. They have processes for adding it — tickets, PRs, reviews, deploys. Deletion doesn't get a ticket. Nobody writes a user story that says "as a developer, I want to remove 38,000 lines of unused code so that I can understand what I'm working on." Maybe they should.

I've started recommending that teams schedule a quarterly dead code audit. Not a massive cleanup — just a half-day where someone runs the analysis tooling, reviews the results, and files deletion PRs for the obvious stuff. The first one takes effort. After that, it's maintenance. Like weeding a garden, it's dramatically easier if you don't let it go for two years.

The codebase isn't haunted anymore. It's just smaller, and the team moves through it without flinching. Sometimes the most productive thing you can do isn't writing new code. It's having the nerve to delete the old stuff.