A translation key can look dead in the current branch and still be live in production. An older app may open a remote catalog that no longer contains the key, leaving a raw identifier, source-language fallback, or blank label on screen. Dynamic key construction creates the same trap because a static scanner cannot see every runtime lookup. To remove unused translation keys safely, generate a read-only candidate report, protect dynamic and versioned consumers, quarantine suspect keys, delete each key across all locales, monitor misses, and keep a tested rollback path.
Why unused-key cleanup breaks production
Translation catalogs grow for ordinary reasons. Features are removed, experiments end, navigation changes, and copy moves between screens. The catalog keeps the old records because deleting them feels riskier than leaving them alone. A current application-team issue describes the resulting maintenance cost as unused i18n keys adding clutter and making translation files harder to maintain. That is a real problem, but clutter is not enough evidence that deletion is safe.
The dangerous assumption is that the current source tree represents every consumer. It does not. A production localization system may have at least four:
- The current mobile source and bundled resources.
- Older installed binaries that still receive backend content or remote catalogs.
- Server, CMS, notification, and experiment code that sends semantic message identifiers.
- Dynamic lookups that build keys from state, such as
error.${code}.
A static analyzer answers a narrow question: which catalog keys can it connect to references it understands? Android's UnusedResources lint rule documents exceptions for test resources and dynamically referenced resources. The i18next CLI unused-key check is deliberately read-only and warns that static analysis cannot detect dynamically constructed keys. Neither warning means the tools are weak. It means their output is a candidate set, not a deletion order.
Define the retirement contract first
Before scanning, decide what "unused" means in your system. Use a definition that engineering, localization, and release owners can verify:
A key is eligible for retirement only when no supported binary, active remote artifact, server payload, dynamic namespace, test fixture, or rollback release can request it.
That definition makes application-version support part of localization cleanup. A key can be unused by the current source tree but still required by an older installed app version. This point is often absent from tool documentation because a source scanner has no knowledge of your minimum supported version, catalog CDN, or rollout policy.
Record these boundaries before producing candidates:
- Oldest app version that still receives backend or catalog updates.
- Catalog schema or artifact version used by each supported app cohort.
- Maximum staged rollout duration and rollback window.
- Owners of server-generated message IDs and experiment configuration.
- Dynamic key namespaces and the code paths allowed to construct them.
- Missing-key behavior in release builds.
If you cannot name the oldest consumer, do not delete shared remote keys. You can still report and quarantine them while you instrument the unknown boundary.
Build a read-only candidate inventory
Start with one canonical key inventory rather than running deletion commands independently in every locale. The source locale usually defines semantic identity, while target locales should match that identity even when plural forms differ.
Collect the following sets:
- Keys present in the source locale.
- Keys present in each target locale.
- Statically referenced keys from application code and layouts.
- Server-authored or CMS-authored message IDs.
- Dynamic namespace patterns and reviewed explicit exceptions.
- Keys observed in missing-key or lookup telemetry.
- Keys added, changed, quarantined, or restored in recent releases.
Apple's String Catalog guide describes source-language catalogs, extracted strings, translation states, and per-language completeness. Preserve those relationships during cleanup. Deleting only one locale creates an extraneous-key or missing-key mismatch rather than completing the retirement.
Produce a machine-readable report without changing catalogs. A compact record might look like this:
key: checkout.card.declined
first_seen_release: 5.4.0
last_static_reference_release: 7.8.0
dynamic_namespace: false
server_producers:
- payments-api
oldest_supported_consumer: 7.2.0
remote_catalog_versions:
- 2026.09.14.1
candidate_reason: no-current-static-reference
quarantine_after: 2026-09-21
delete_after: 2026-10-19
owner: checkout-platform
The server_producers field blocks deletion in this example even though the current app source has no direct reference. The result should fail review until the payments service stops emitting the identifier or every supported app can map a replacement.
Protect dynamic translation keys explicitly
Dynamic key construction needs a reviewed allowlist because static analysis cannot prove those keys are dead. A broad suppression such as errors.* avoids false deletions, but it also lets abandoned keys accumulate forever. Use a bounded rule tied to code and ownership instead.
Prefer an explicit registry when the possible values are finite:
const paymentErrorMessageKeys = {
card_declined: 'checkout.error.cardDeclined',
expired_card: 'checkout.error.expiredCard',
network: 'checkout.error.network',
} as const;
export function paymentErrorKey(code: keyof typeof paymentErrorMessageKeys) {
return paymentErrorMessageKeys[code];
}
The registry is discoverable by static tools, reviewable in a pull request, and easy to compare with the catalog. If the domain is too large for an explicit map, define a namespace manifest with a producer, allowed pattern, source of possible values, and expiry date. Reject unowned wildcard exceptions in CI.
Use runtime telemetry as a countercheck, not proof of safety. A missing-key event can show that deletion was wrong. The absence of an event cannot prove a key is unused because the relevant flow may be rare, offline, region-specific, or absent during the observation window. Log the key identifier, app version, effective locale, catalog version, and feature surface. Do not log interpolated user text or personal data.
Quarantine before permanent deletion
Quarantine retired keys for one release window and keep a rollback manifest before permanent deletion. This extra state is different from leaving clutter indefinitely. A quarantined key has an owner, reason, deadline, and known restoration source.
A practical sequence is:
- Generate the candidate report in CI and attach it to a pull request.
- Remove current code and server references while leaving catalog values intact.
- Mark approved candidates in a versioned retirement manifest.
- Ship the no-reference release and wait through the declared rollout window.
- Check missing-key events by app version, locale, catalog version, and feature.
- Delete the keys from the source and every target locale in one change.
- Publish a catalog artifact whose minimum consumer version is explicit.
- Keep the previous catalog and retirement manifest available for rollback.
This sequence matters most when catalogs can update independently of app binaries. A catalog deletion should not reach a cohort whose binary predates the code change. Gate catalog activation by minimum app version, or keep deprecated keys in the artifact served to older cohorts. If your delivery layer cannot segment safely, delay physical deletion until those binaries are no longer supported.
A Flutter developer's question about how to safely remove one key or all unused keys across ARB and generated files shows why a multi-file operation needs a defined owner. Generated files should be regenerated from the canonical catalog, not edited independently. Review the resulting diff for every locale and generated binding.
Add a CI gate that reports before it deletes
Keep detection and mutation as separate commands. The reporting step can run on every pull request. Deletion should require an approved manifest and a deliberate maintenance change.
scan catalogs and source references
load dynamic-key registry and server message-ID inventory
subtract protected and currently referenced keys
join candidates with app-version and catalog-version support data
reject candidates without owner, quarantine date, or rollback source
write unused-keys-report.json
exit nonzero when new unreviewed candidates appear
The i18next CLI follows this useful separation: status --unused reports candidates and exits nonzero without modifying files, while extraction configuration can perform removal. Apply the same design even if your platform uses Android XML, Apple string catalogs, Flutter ARB, or a custom localization service.
Set CI policy around changes, not total historical debt. A legacy repository might contain hundreds of old keys. Blocking all work until they disappear encourages broad suppressions. Instead, reject newly unreviewed candidates, require owners for existing quarantine records, and schedule bounded cleanup batches.
Handle failures without hiding them
A safe missing-key fallback protects users while preserving a signal for engineers. Release behavior should never crash, but a silent fallback can hide a bad deletion for months. Return a bundled source-language value or a stable generic message where the product allows it, then emit a rate-limited structured event.
Use these decision rules when an event appears after quarantine or deletion:
- Only older app versions miss the key: restore it for those catalog cohorts and correct the minimum-version gate.
- One dynamic flow misses it: restore the key, add that flow to the registry, and add a test fixture.
- Only one locale misses it: restore locale parity and fix the atomic catalog update process.
- A server payload still emits it: restore the key, version the payload contract, and migrate the producer before retrying retirement.
- Rollback reintroduces the old code: serve the previous compatible catalog with the rolled-back binary.
Do not reuse a retired key for new copy. Reuse destroys the meaning of historical telemetry and can make an older binary display text intended for a different feature. Create a new semantic key when the meaning changes.
Verify deletion across the release lifecycle
A green static-analysis run is only the first check. Verify the entire lifecycle with a small matrix:
| Check | Required result |
|---|---|
| Current source scan | No unexplained reference to the candidate key |
| Dynamic registry | Candidate is absent or its exception is explicitly revoked |
| Server and CMS inventory | No active producer emits the key |
| Locale parity | Source and every target remove the same semantic key set |
| Oldest supported binary | Compatible catalog still contains every key it can request |
| Current binary with new catalog | No raw key, blank value, or unintended fallback |
| OTA rollback | Previous catalog can be restored without schema errors |
| Missing-key telemetry | No new event for the retired key during the observation window |
| Offline launch | Bundled fallback remains usable without the remote catalog |
Also test plural and variant families as one semantic unit. Removing only items.one while leaving items.other, or deleting a device variation while retaining its base entry, can produce locale-specific failures that a flat key count misses.
Audit the retirement manifest after the observation window. Every entry should end in one of three states: deleted, restored with a documented counterexample, or extended with a reason and new review date. An endless quarantine is just clutter with more metadata.
Make the first cleanup reversible
Choose one bounded namespace or retired feature for the first pass. Run the scanner in report-only mode, review dynamic and remote consumers with their owners, and record the minimum supported app and catalog versions. Quarantine the approved set for one full release window. Delete across every locale only after the checks stay clean, then exercise the rollback manifest in a test environment before closing the cleanup task.
Retiring keys is easier when the remaining ones never change meaning; see how to control source string changes before they cause translation drift.
