Feature Flag Cleanup Strategy

How I keep feature flag debt under control: lifecycle policies, jscodeshift codemods that strip stale conditionals, and CI guardrails that block expired flags from shipping.

The flag was old. I found it on a Tuesday afternoon, grepping through the creator-economy platform’s frontend monorepo for something unrelated, when an if (flags.newCourseEditor) lit up in a file I didn’t recognize. The branch had shipped to 100% of users long ago. The else branch was a fossilized version of the old editor still in the bundle.

I traced it. Three other flags lived in the same component. Two dead, one a percentage rollout nobody remembered approving. The combined dead code added up to several hundred kilobytes of JavaScript every customer downloaded on every page load. That’s the part that bugs me about flag debt. It doesn’t show up on a dashboard. It shows up as drift you only notice when someone goes looking.

Here’s the thing I’ve settled on. Flags need a lifecycle policy. You strip stale ones with codemods, not by hand. And you block expired ones at CI so they can’t accumulate again.

A flag has a birthday and a death date

Every flag we create now carries an expiry. Not a vibe, an actual date in the source. The contract is dead simple: at creation time, you write down when the flag is supposed to be gone. If you don’t know, the default is 90 days.

// src/flags/registry.ts
import type { FlagDefinition } from "./types";
import { daysAgo, daysFromNow } from "./dates";

export const FLAGS = {
  newCourseEditor: {
    description: "Roll out the redesigned course editor",
    owner: "growth-fe",
    createdAt: daysAgo(40),
    expiresAt: daysFromNow(50),
    kind: "release",
  },
  experimentPricingV2: {
    description: "A/B test the new pricing page hero",
    owner: "monetization",
    createdAt: daysAgo(20),
    expiresAt: daysFromNow(70),
    kind: "experiment",
  },
} as const satisfies Record<string, FlagDefinition>;

export type FlagKey = keyof typeof FLAGS;

The kind matters. Release flags die in weeks. Experiment flags die when the experiment ends. Ops flags (kill switches, circuit breakers) can live indefinitely but must be tagged that way explicitly, not by accident.

I keep the registry as a TypeScript file rather than a JSON blob from a flag-service SDK because we want the types to fail loudly. If your IDE doesn’t autocomplete flags.someExpiredFlag after you’ve removed it, you find every call site in seconds.

Read flags through a typed hook

The application code shouldn’t know the registry exists. It asks a hook, gets a boolean, and moves on. No string keys floating loose in components.

// src/flags/useFlag.ts
import { useContext } from "react";
import { FlagContext } from "./context";
import type { FlagKey } from "./registry";

export function useFlag(key: FlagKey): boolean {
  const ctx = useContext(FlagContext);
  if (!ctx) {
    throw new Error("useFlag called outside FlagProvider");
  }
  return ctx.evaluate(key);
}

The provider hides the evaluator (LaunchDarkly, our own service, whatever). Call sites stay clean.

// src/courses/CourseEditorRoot.tsx
import { useFlag } from "@/flags/useFlag";
import { CourseEditorLegacy } from "./CourseEditorLegacy";
import { CourseEditorV2 } from "./CourseEditorV2";

export function CourseEditorRoot(props: CourseEditorProps) {
  const useV2 = useFlag("newCourseEditor");
  return useV2 ? <CourseEditorV2 {...props} /> : <CourseEditorLegacy {...props} />;
}

This is the shape that’s easiest to remove later, by the way. One call site, one boolean, both branches imported. A codemod can resolve this in its sleep.

Strip dead flags with jscodeshift

The mistake I made for a long time was assuming we’d clean flags up by hand once they shipped. That never happens. So we automate it. Once a flag is at 100% and verified, a codemod inlines the winning branch and deletes the rest. jscodeshift handles the AST work.

// scripts/codemods/inline-flag.ts
import type { API, FileInfo, JSCodeshift } from "jscodeshift";

type Opts = { flag: string; value: "true" | "false" };

export default function transform(file: FileInfo, api: API, opts: Opts) {
  const j: JSCodeshift = api.jscodeshift;
  const root = j(file.source);
  const targetValue = opts.value === "true";

  root
    .find(j.CallExpression, { callee: { name: "useFlag" } })
    .filter((p) => {
      const arg = p.node.arguments[0];
      return arg.type === "StringLiteral" && arg.value === opts.flag;
    })
    .forEach((callPath) => {
      const declarator = j(callPath).closest(j.VariableDeclarator).get(0);
      if (!declarator) return;
      const varName = declarator.node.id.name;

      root
        .find(j.ConditionalExpression, { test: { name: varName } })
        .forEach((p) => {
          j(p).replaceWith(targetValue ? p.node.consequent : p.node.alternate);
        });

      root
        .find(j.IfStatement, { test: { name: varName } })
        .forEach((p) => {
          const branch = targetValue ? p.node.consequent : p.node.alternate;
          if (!branch) {
            j(p).remove();
            return;
          }
          j(p).replaceWith(branch);
        });

      j(declarator).remove();
    });

  return root.toSource({ quote: "double" });
}

You run it like jscodeshift -t scripts/codemods/inline-flag.ts --flag=newCourseEditor --value=true src/. It rewrites the conditional, removes the variable, lets Prettier fix whitespace, and ESLint’s unused-import rule cleans up on the next CI pass.

This transform handles the common cases. Ternary, if/else, single-variable assignment. It does not handle a flag value passed three levels deep as a prop. For those, the codemod prints a warning and a human resolves it. The 90% happens automatically, the 10% shows up on a list someone owns.

Block expired flags at the CI layer

The lifecycle policy is worthless if nothing enforces it. So we wrote a CI check. It reads the registry, walks every flag, and fails the build if anything is past its expiresAt date.

// scripts/check-expired-flags.ts
import { FLAGS } from "../src/flags/registry";

const today = new Date().toISOString().slice(0, 10);
const grace = 14;

function daysBetween(a: string, b: string): number {
  const ms = new Date(b).getTime() - new Date(a).getTime();
  return Math.floor(ms / 86_400_000);
}

const expired: string[] = [];
const warning: string[] = [];

for (const [key, def] of Object.entries(FLAGS)) {
  if (def.kind === "ops") continue;
  const overdue = daysBetween(def.expiresAt, today);
  if (overdue > grace) {
    expired.push(`${key} (${overdue}d overdue, owner: ${def.owner})`);
  } else if (overdue > 0) {
    warning.push(`${key} (${overdue}d past expiry)`);
  }
}

if (warning.length > 0) console.warn("flags nearing cleanup:\n  " + warning.join("\n  "));

if (expired.length > 0) {
  console.error("expired flags must be removed:\n  " + expired.join("\n  "));
  process.exit(1);
}

Wire that into a GitHub Actions job on every PR plus a nightly cron, and flag debt becomes visible the moment it forms. The 14-day grace period keeps it from blocking a Friday deploy because a flag expired on Thursday. The cron files a ticket so the owner can’t pretend they didn’t see it.

Takeaways

  • Every flag has an expiresAt at birth. Default to 90 days if the owner won’t commit.
  • Read flags through a typed hook. Loose string keys make every cleanup harder.
  • Strip stale flags with a jscodeshift codemod. Hand-cleanup never happens.
  • Block expired flags at CI. The grace period is the kindness, the build failure is the contract.
  • The 10% the codemod can’t handle is the list someone owns. The 90% is the work that shouldn’t need a human.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.