7 min read

Deleting Code Is a Feature

Dead code charges rent daily and nobody bills it. The real costs, the reachability probe that beats every static analyser, where dead code hides on Android, and how to delete safely without the ceremony that stops people trying.

Legacy CodeAndroidRefactoringEngineering CultureMaintenance
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Dead code is the cheapest thing to leave and it charges rent every day: it's read by every newcomer, compiled and kept green, it pins dependencies you can't upgrade, it poisons every search, and occasionally it turns out not to be dead. Static analysis under-reports on Android because of reflection, DI and resources — so use a reachability probe: log once when a legacy path executes, ship it, wait a month, read the number. Feature flags at 100% for more than one release are dead branches. Delete in a standalone commit so revert is trivial, and stop treating "we might need it" as an argument — git already answered it.

The bill nobody sends

Leaving code costs nothing today, which is why it survives. Here's the invoice it's quietly generating.

Every new engineer reads it. It's in the repo, it compiles, it has tests — so it must matter. They spend an hour understanding a feature that was switched off in 2023, and they're slightly less sure about everything nearby afterwards.

You pay to keep it green. It compiles on every build, its tests run on every CI job, and when they break someone fixes them. You are spending money proving that unused code still works.

It pins your dependencies. The upgrade you can't do is frequently blocked by a handful of call sites in a feature nobody has shipped in two years. The dead code isn't just idle — it's actively holding the codebase back.

It poisons search. Every grep, every "find usages", every rename refactor includes it. On a large codebase this is a real tax on the speed of every future change.

It's a migration target. Every Kotlin migration, every Compose migration, every dependency-injection change drags it along. Teams routinely migrate dead code with great care because nobody checked whether it was alive.

And sometimes it isn't dead. A path you assumed unreachable turns out to be reachable from one deep link or one push payload. Now you have a bug in code nobody has read for three years, in a flow with no tests anyone trusts.

Finding it: the reachability probe

Static analysis under-reports badly on Android. Reflection, dependency injection, Class.forName, XML-declared components, resource references and R8's -keep rules all defeat it. IDE "unused symbol" hints are a starting point and nothing more.

The technique that actually works:

fun legacyCheckout(cart: Cart) {
    analytics.legacyPathHit("legacyCheckout")   // once per session
    …
}

Ship it. Wait a month. Read the number.

Coverage tells you what your tests run. This tells you what your users run — which is the only question that matters for deletion. A month of zero across your whole install base is the strongest possible argument, and it converts a debate into a fact.

Practical notes: rate-limit to once per session so a loop doesn't dominate your analytics bill; include the app version so you can distinguish "nobody uses it" from "nobody on the new version uses it"; and leave it running for a full monthly cycle, because billing flows, tax-year features and monthly-active users produce genuinely seasonal usage.

Where it hides on Android

  • Feature flags at 100% for more than one release cycle. Not a flag — a dead branch behind a config lookup, plus the entire "off" path underneath it.
  • The else branch of an experiment that concluded eighteen months ago.
  • Old API-level compatibility branches. if (SDK_INT < 26) when your minSdk is 26.
  • Unused resources, especially drawables and strings — shrinkResources removes them from the APK but not from your repository, your translation bill or your search results.
  • Screens reachable only from a navigation route nothing links to.
  • Whole modules that nothing depends on. Check your dependency graph; there's usually one.
  • @Deprecated symbols past their removal date — the previous post in this series is about making sure that date exists.

Deleting safely

One commit, nothing else in it. No behaviour changes, no drive-by cleanup, no reformatting. This is the single most important rule: a pure deletion is trivially reviewable and trivially revertable. Mix in one "while I'm here" fix and you've lost both properties.

Delete the tests with it. Tests for deleted code aren't coverage, they're archaeology. Leaving them produces the compile errors that make people abandon the deletion halfway.

Do it early in a release cycle, not the week before a freeze. If it goes wrong you want soak time and a staged rollout, both of which you have on day two and not on day nineteen.

Watch the crash-free rate on the next release with the same halt criteria as any other change. That's the verification, and it's usually the whole verification.

"We might need it later"

Git already answered this. Retrieval is thirty seconds:

git log --diff-filter=D --oneline -- '*LegacyCheckout*'
git show <sha>^:app/src/main/java/.../LegacyCheckout.kt

In fifteen years I have restored deleted code exactly twice, and both times rewriting it would have been faster — because by the time you want it back, the surrounding code has moved and the old version doesn't fit anyway.

What's actually being expressed by "we might need it" is usually fear of an unknown reachability, which is a legitimate concern with a real answer: that's what the probe is for. Distinguish the two. Fear of deletion deserves data; sentiment about deletion doesn't deserve a veto.

The cultural half

Nobody demos a deletion. There's no screenshot, no launch, no metric that goes up. Meanwhile shipping a feature is visible and rewarded. So the incentives point one way and the codebase grows monotonically.

Small things that shift it:

  • Put lines deleted in the release notes, next to lines added. It takes one line and it makes the work visible.
  • Make flag removal part of the rollout plan, with an owner and a date, rather than a backlog ticket. Backlog tickets for cleanup are where cleanup goes to die.
  • Celebrate the module that disappeared. A build that's ninety seconds faster because a module is gone is a better outcome than most features, and it should be said out loud.
  • Budget it. A recurring day per quarter beats a heroic "cleanup sprint" that never gets scheduled.

The counter-case

Two situations where you should slow down:

Don't delete and change behaviour in the same breath. If you're removing an old path and modifying the new one, do them in separate commits — otherwise a revert restores a broken combination.

Don't delete code you don't understand while under time pressure. "It looks unused" at 6pm before a release is how you find out what it did. Probe it, wait, then delete it calmly.

The rule

Deletion is the only change that can't introduce a bug in the thing it removes. Every line you delete is a line that can't break, can't mislead a newcomer, can't pin a dependency, and can't be migrated ever again. Probe first if you're unsure, delete in a commit of its own, and count it as shipped work — because it is.

Keep reading