A developer on your team opens a pull request titled "Remove old feature flag." The diff looks simple -- delete a few conditionals, clean up some imports, remove a test mock. The reviewer approves it in three minutes. The merge goes through. The deploy rolls out.
Twenty minutes later, your on-call engineer's phone starts buzzing. A payment processing endpoint is returning 500 errors for 12% of users. The flag that was "just an old if-statement" was also controlling a fallback path for a third-party API integration that nobody documented. The "simple" removal just cost your company $47,000 in failed transactions before the rollback completed.
This scenario plays out across the industry more often than anyone admits. In our experience, flag removal is a surprisingly common root cause of configuration-related production incidents. Not flag addition -- flag removal. The act of cleaning up is itself a source of outages when done carelessly.
The irony is brutal: teams that diligently clean up their technical debt are being punished for it. Not because cleanup is wrong, but because the process of removing flags demands the same rigor as adding them -- and most teams treat removal as an afterthought.
This guide is your insurance policy. Follow it, and your flag removal PRs will be safe, reviewable, and rollback-ready.
Before you write a single line of code
The most important work in a flag removal PR happens before you open your editor. Skip this pre-removal checklist and you are gambling with production stability.
Pre-removal checklist
| Check | How to Verify | Stop If |
|---|---|---|
| Flag is 100% ON or 100% OFF | Check management platform targeting rules | Flag has any percentage rollout or user targeting |
| Flag has been stable for 30+ days | Check platform evaluation history | Flag was modified in the last 30 days |
| No other flags depend on this flag | Search codebase for flag key in conditionals | Flag appears inside another flag's conditional block |
| Flag owner confirms removal is safe | Ping the original author or current feature owner | Owner says "not yet" or is unreachable |
| Flag is not a kill switch | Check flag naming convention and documentation | Flag is documented as an operational kill switch |
| All environments have the same state | Compare flag state across dev/staging/production | Flag is ON in production but OFF in staging |
| No scheduled changes pending | Check platform for scheduled flag changes | A scheduled change exists for this flag |
If any check fails, stop. Investigate before proceeding. A flag that seems ready for removal but fails one of these checks is a flag that will bite you in production.
Confirming the winning code path
This is the single most important pre-removal step, and the one most often skipped. You need to know with absolute certainty which code path is the "winner" -- the path that should remain after the flag is removed.
For a flag that is 100% ON:
- The
truebranch is the winning path - The
false/elsebranch is dead code to be removed
For a flag that is 100% OFF:
- The
false/elsebranch is the winning path - The
truebranch is dead code to be removed
Verify this in production, not just in code. Check your flag management platform's evaluation logs to confirm that real traffic is hitting the expected path. A flag can be configured as 100% ON in the platform but still evaluate to false in code due to default values, SDK initialization failures, or caching issues.
# Verify the flag's production state by checking evaluation logs
# LaunchDarkly example:
# Check the "Insights" tab for your flag in the production environment
# Confirm 100% of evaluations return the expected variation
# If you have structured logging, verify in your logs:
grep "flag-key-name" /var/log/app/production.log | \
awk '{print $NF}' | sort | uniq -c
# You should see only ONE variation value in the output
Anatomy of a good flag removal PR
A flag removal PR is not just a code change. It is a communication artifact that tells reviewers, future engineers, and your on-call team exactly what changed, why, and what to do if something goes wrong.
PR title format
Use a consistent, scannable format:
flag-cleanup: Remove [flag-key] - [one-line description]
Examples:
flag-cleanup: Remove enable-new-checkout - feature fully rolled out since Julyflag-cleanup: Remove experiment-pricing-v3 - experiment concluded, variant B wonflag-cleanup: Remove temp-fix-api-timeout - underlying API issue resolved
PR description template
Every flag removal PR should include this information:
## Flag Removal: `flag-key-name`
### Summary
Removing the `flag-key-name` feature flag. This flag controlled [brief description
of what the flag did]. It has been [100% ON / 100% OFF] in all environments
since [date].
### Pre-Removal Verification
- [x] Flag is 100% [ON/OFF] in production since [date]
- [x] Flag is 100% [ON/OFF] in staging since [date]
- [x] No other flags depend on this flag
- [x] Flag owner (@username) confirmed removal is safe
- [x] Flag is not a kill switch or operational toggle
- [x] No scheduled changes pending in flag platform
### What This PR Does
- Removes flag evaluation from [list files]
- Preserves the [true/false] code path (winning variant)
- Removes dead [true/false] code path
- Updates [X] test files to remove flag mocking
- Removes flag from [config file / management platform] (if applicable)
### Code Path Preserved
**Before:** Traffic is split by flag evaluation
**After:** All traffic follows the [winning path] unconditionally
### Testing
- [ ] Unit tests pass without flag mocking
- [ ] Integration tests verify preserved behavior
- [ ] Manual verification in staging environment
### Rollback Plan
If this removal causes issues:
1. Revert this PR (git revert [commit-hash])
2. Re-create the flag in [platform] with value [ON/OFF]
3. Deploy the revert
4. Investigate the root cause before re-attempting removal
### Related
- Original flag PR: [link]
- Flag platform link: [link]
- Feature documentation: [link]
This template takes less than five minutes to fill out and saves hours of debugging if something goes wrong. Reviewers can verify the removal is safe without needing to reverse-engineer the flag's history.
The code changes: Removing flags correctly
Flag removal involves more than deleting if statements. Depending on the pattern, you need to handle conditionals, imports, test mocks, configuration files, and sometimes database entries. Let's walk through the most common patterns.
Pattern 1: Simple boolean flag (true branch wins)
This is the most common case. A boolean flag wraps a feature, and the feature is now fully rolled out.
Before:
import { getFeatureFlag } from '@company/flags';
export async function processOrder(order: Order): Promise<Result> {
const useNewProcessor = await getFeatureFlag('enable-new-order-processor', user);
if (useNewProcessor) {
// New order processing logic
const result = await newOrderProcessor.process(order);
await analyticsService.trackNewFlow(order.id);
return result;
} else {
// Legacy order processing
const result = await legacyOrderProcessor.process(order);
return result;
}
}
After:
export async function processOrder(order: Order): Promise<Result> {
const result = await newOrderProcessor.process(order);
await analyticsService.trackNewFlow(order.id);
return result;
}
What changed:
- Removed the flag import (if no other flags use it in this file)
- Removed the
if/elseconditional entirely - Preserved only the winning (
true) branch code - Removed the dead (
else) branch completely - Removed the flag variable declaration
Pattern 2: Simple boolean flag (false branch wins)
Less common, but it happens when an experiment fails or a feature is rolled back permanently.
Before:
func (s *Service) GetPricingTier(ctx context.Context, userID string) (*Tier, error) {
useNewPricing, err := s.flags.BoolVariation("experiment-dynamic-pricing", ctx, false)
if err != nil {
log.Warn("flag evaluation failed, using default pricing", "error", err)
useNewPricing = false
}
if useNewPricing {
return s.dynamicPricingEngine.Calculate(ctx, userID)
}
return s.standardPricingEngine.Calculate(ctx, userID)
}
After:
func (s *Service) GetPricingTier(ctx context.Context, userID string) (*Tier, error) {
return s.standardPricingEngine.Calculate(ctx, userID)
}
What changed:
- Removed the flag evaluation and its error handling
- Removed the winning experiment path (dynamic pricing)
- Preserved the control path (standard pricing) as the unconditional return
- The function signature stays identical -- callers are not affected
Pattern 3: Flag with early return
Before:
def send_notification(user, message):
if not feature_flags.is_enabled("enable-push-notifications", user):
return send_email_notification(user, message)
devices = get_user_devices(user)
if not devices:
return send_email_notification(user, message)
return send_push_notification(devices, message)
After (flag was ON -- push notifications are the winning path):
def send_notification(user, message):
devices = get_user_devices(user)
if not devices:
return send_email_notification(user, message)
return send_push_notification(devices, message)
Be careful here. Early return patterns are easy to mess up because the flag-controlled return might be the first line of the function. Removing the wrong branch can completely change the function's behavior.
Pattern 4: Flag controlling a function argument or configuration
Before:
const cacheConfig = {
ttl: featureFlags.isEnabled('extended-cache-ttl') ? 3600 : 300,
maxSize: featureFlags.isEnabled('extended-cache-ttl') ? 10000 : 1000,
strategy: 'lru',
};
After (flag was ON):
const cacheConfig = {
ttl: 3600,
maxSize: 10000,
strategy: 'lru',
};
Pattern 5: Flag with multiple references across files
When a flag is evaluated in multiple files, you must remove all references in a single PR. Partial removal is dangerous because it creates inconsistent behavior.
# Find ALL references to the flag key before starting
grep -rn "enable-new-checkout" \
--include="*.ts" --include="*.tsx" --include="*.js" \
--include="*.go" --include="*.py" \
--include="*.yaml" --include="*.json" \
--exclude-dir=node_modules --exclude-dir=vendor \
.
Every result from this search must be addressed in your PR. If you find references in files you do not own, coordinate with those teams before merging.
Pattern 6: Nested flags
This is the most dangerous pattern. Two or more flags interact within the same code block.
Before:
if (flags.isEnabled('new-dashboard')) {
if (flags.isEnabled('dashboard-analytics-panel')) {
return <DashboardWithAnalytics />;
}
return <NewDashboard />;
}
return <LegacyDashboard />;
If removing new-dashboard (which is ON):
if (flags.isEnabled('dashboard-analytics-panel')) {
return <DashboardWithAnalytics />;
}
return <NewDashboard />;
If removing dashboard-analytics-panel (which is ON):
if (flags.isEnabled('new-dashboard')) {
return <DashboardWithAnalytics />;
}
return <LegacyDashboard />;
Rule: Only remove one flag at a time from nested flag blocks. Removing multiple nested flags in a single PR makes it exponentially harder to verify correctness and nearly impossible to diagnose issues if the removal causes problems.
Cleaning up beyond the code
Flag removal involves more than the application code. A thorough PR addresses all the places a flag leaves traces.
Import and dependency cleanup
After removing flag evaluations, check whether the flag SDK import is still needed in each modified file:
# For TypeScript: check if the import is still referenced
# After removing flag calls, if no other flag calls exist in the file,
# remove the import line:
# import { getFeatureFlag } from '@company/flags';
Test updates
Flag removal always requires test changes. Here is what to look for:
| Test Artifact | Action |
|---|---|
Flag mock setup (jest.mock, testify/mock) | Remove mock if no other flags tested in file |
| Test cases for both flag branches | Remove test for dead branch, keep test for winning branch |
| Test fixtures with flag values | Remove flag from fixture data |
| Integration test flag configuration | Remove flag from test environment config |
| E2E test flag overrides | Remove flag override from test setup |
Before (test file):
describe('processOrder', () => {
it('should use new processor when flag is enabled', async () => {
mockGetFeatureFlag.mockResolvedValue(true);
const result = await processOrder(testOrder);
expect(newOrderProcessor.process).toHaveBeenCalledWith(testOrder);
});
it('should use legacy processor when flag is disabled', async () => {
mockGetFeatureFlag.mockResolvedValue(false);
const result = await processOrder(testOrder);
expect(legacyOrderProcessor.process).toHaveBeenCalledWith(testOrder);
});
});
After:
describe('processOrder', () => {
it('should process order with new processor', async () => {
const result = await processOrder(testOrder);
expect(newOrderProcessor.process).toHaveBeenCalledWith(testOrder);
});
});
The test no longer mocks the flag, no longer tests both branches, and now directly tests the unconditional behavior.
Configuration and platform cleanup
| Cleanup Item | When to Do It | Notes |
|---|---|---|
| Remove flag from management platform | After PR merges and deploys successfully | Wait 24-48 hours post-deploy |
Remove flag from .env files | In the same PR | Prevents confusion |
| Remove flag from CI/CD config | In the same PR | If flag was used in pipeline |
| Remove flag from documentation | In the same PR or follow-up | Update feature docs |
| Remove flag from monitoring/alerting | After PR deploys | Remove flag-specific alerts |
Important: Do not remove the flag from your management platform in the same step as deploying the code change. Keep the platform configuration intact for at least 24-48 hours after the code deploys. This gives you a fast rollback path -- you can revert the code and the flag is still configured correctly.
Testing strategy for flag removal
Testing a flag removal PR requires a different approach than testing new feature code. You are not verifying that something new works; you are verifying that removing code does not change existing behavior.
Unit test strategy
- Run existing tests first without any changes. They should all pass. If they don't, fix the failing tests before starting the flag removal.
- Remove flag mocks and update test expectations to match the winning code path.
- Delete tests for the dead code path. These tests are now testing code that no longer exists.
- Run the full test suite and verify everything passes.
Integration test strategy
Integration tests are where flag removal issues most commonly surface. The flag might be controlling behavior that integration tests depend on without explicitly mocking.
# Run integration tests with verbose output to catch subtle failures
go test -v -count=1 ./integration/...
# For JavaScript/TypeScript projects
npm run test:integration -- --verbose
# Watch for tests that pass but produce warnings about missing flags
Staging verification
Before merging, deploy the flag removal branch to a staging environment and verify:
| Verification | Method | Pass Criteria |
|---|---|---|
| Happy path works | Manual test of primary user flow | Same behavior as production |
| Error handling works | Trigger known error conditions | Errors handled gracefully |
| Performance is stable | Compare response times to production | Within 5% of production p95 |
| No new errors in logs | Monitor application logs for 15 minutes | No new error patterns |
| Dependent services unaffected | Check downstream service health | All green |
Canary deployment
For high-traffic services or flags that controlled critical functionality, use a canary deployment strategy:
- Deploy the flag removal to 5% of production traffic
- Monitor error rates, latency, and business metrics for 30 minutes
- If metrics are stable, increase to 25%, then 50%, then 100%
- At each stage, compare metrics against the baseline
This approach catches issues that only manifest at scale or with specific user segments.
The rollback plan
Every flag removal PR needs a documented rollback plan. Not because you expect to use it, but because the 2 AM version of you will be grateful it exists.
Fast rollback: Git revert
The fastest rollback is a git revert of the removal commit:
git revert <commit-hash>
git push origin main
# Deploy the revert through your normal pipeline
This restores the flag evaluation code. But for the flag to work again, it must still exist in your management platform. This is why you wait 24-48 hours before deleting the flag from your platform.
Slower rollback: Re-create the flag
If you already deleted the flag from your platform, you need to both revert the code and re-create the flag configuration:
- Revert the code change
- Re-create the flag in your management platform with the same key
- Set the flag to the same state it was in before removal (usually 100% ON)
- Deploy the code revert
- Verify the flag is evaluating correctly
This is slower and error-prone, which reinforces why platform cleanup should be a separate, later step.
When to roll back
Roll back immediately if you observe any of these within 1 hour of deployment:
- Error rate increases by more than 0.5%
- p95 latency increases by more than 20%
- Any new 5xx error type appears
- Business metrics (conversion, revenue, sign-ups) drop by more than 2%
- Downstream services report increased error rates
Do not wait to diagnose the cause. Roll back first, investigate second. A 10-minute outage is always cheaper than a 2-hour diagnosis while users are impacted.
Common pitfalls and how to avoid them
Pitfall 1: Removing the wrong code path
This is the most common flag removal mistake. The developer assumes the true branch is the winner but the flag is actually OFF in production.
Prevention: Always verify the flag's production state in your management platform before coding. Include a screenshot of the flag's evaluation data in your PR description.
Pitfall 2: Missing references in non-obvious locations
Flags often appear in places beyond the main application code:
- Configuration files (YAML, JSON, TOML)
- Database seed scripts
- CI/CD pipeline configuration
- Feature documentation and runbooks
- Monitoring and alerting rules
- API documentation and OpenAPI specs
- Client-side code (if the flag is evaluated in both frontend and backend)
Prevention: Run a comprehensive search across all file types, not just source code:
grep -rn "your-flag-key" . \
--exclude-dir=node_modules \
--exclude-dir=vendor \
--exclude-dir=.git
Pitfall 3: Shared flag keys across services
In microservice architectures, the same flag key might be evaluated in multiple services. Removing the flag in one service while it is still active in another creates inconsistent behavior.
Prevention: Search across all repositories, not just the one you are working in. If your organization uses a monorepo, this is straightforward. For multi-repo setups, check with teams that share the flag's domain.
Pitfall 4: Config-driven flags with runtime evaluation
Some flags are not evaluated through SDK method calls but through configuration that is read at runtime:
{
"features": {
"enable-new-checkout": true,
"enable-dark-mode": false
}
}
These do not appear in a grep for SDK method calls. They require searching for the flag key string across all config formats.
Pitfall 5: Forgetting about feature flag default values
When you remove a flag and the SDK cannot find it, it returns the default value specified in code. If your code specifies false as the default and the flag is ON in the platform, removing the platform entry without removing the code will cause the code to suddenly evaluate to false.
Prevention: Always remove code first, deploy, verify, then remove from the platform.
Pitfall 6: Removing flags that are still used for analytics
Some flags are referenced in analytics event properties even after the feature is fully rolled out:
analytics.track('purchase_completed', {
featureFlag: featureFlags.isEnabled('new-checkout'),
// other properties...
});
Removing the flag will cause this analytics property to change from true to undefined or false, which can break dashboards and reports.
Prevention: Search for the flag key in analytics calls and coordinate with your data team before removal.
The PR review checklist
For reviewers evaluating a flag removal PR, use this checklist:
| Review Item | Verified |
|---|---|
| PR description includes pre-removal checklist | |
| Flag state verified in production platform | |
| Correct code path preserved (matches production state) | |
| All references to flag key removed (check grep output) | |
| Unused imports cleaned up | |
| Dead code path fully removed (no orphaned functions) | |
| Tests updated: dead path tests removed | |
| Tests updated: winning path tests no longer mock the flag | |
| No other flags nested inside the removed flag's conditionals | |
| Rollback plan documented in PR description | |
| Platform cleanup noted as post-merge follow-up |
A flag removal PR that fails any of these items should not be approved. It takes more discipline to review a removal PR than a feature PR, because the consequences of mistakes are harder to detect in review and harder to diagnose in production.
Automating safe removal
The manual process described above works, but it is labor-intensive. For teams removing flags regularly, automation dramatically reduces both the effort and the risk.
Tools like FlagShark can detect when flags reach 100% rollout and automatically generate removal PRs that follow the patterns described in this guide -- preserving the correct code path, cleaning up imports, and updating tests. This transforms flag cleanup from a dreaded chore into an automated part of your development workflow.
Whether you automate or not, the principles remain the same: verify before you remove, preserve the right code path, test thoroughly, and always have a rollback plan.
Flag removal is not glamorous work. It does not ship new features or impress stakeholders in sprint demos. But every flag you remove safely is a reduction in complexity, a faster code review for the next engineer, and one less potential failure mode in production.
The teams that build reliable software are not the ones that never break things. They are the ones that build processes robust enough to make even the mundane work -- like removing an old if-statement -- predictably safe.
Write your next flag removal PR like production depends on it. Because it does.