Feature Flags Started as If Statements. They Became Infrastructure.
The first feature flag I ever wrote was a boolean in a config file: ENABLE_NEW_CHECKOUT=true. It worked fine for a team of four. By the time the team grew to twenty and we had 200+ flags scattered across three services, that approach had turned into a maintenance nightmare of stale flags, conflicting states, and a config file that nobody dared to clean up because nobody knew which flags were still in use.
Feature flags at scale require real tooling — either a managed service like LaunchDarkly or Unleash, or a well-designed homegrown system. This article compares the major options and walks through the decision framework for choosing between them.
What Feature Flags Actually Do at Scale
A simple boolean toggle is a feature flag. But at scale, feature flags serve multiple distinct purposes that require different capabilities:
| Use Case | What It Needs | Example |
|---|---|---|
| Release toggles | Simple on/off per environment | Enable new search UI in staging |
| Experiment flags | Percentage rollout, A/B testing | Show new pricing page to 10% of users |
| Ops toggles | Kill switches, instant disable | Disable recommendation engine under load |
| Permission flags | User/segment targeting | Enable beta features for enterprise plan |
| Canary flags | Gradual rollout with metrics | Roll out new payment flow region by region |
A config file can handle the first use case. Everything else requires something more sophisticated.
LaunchDarkly: The Enterprise Standard
LaunchDarkly is the most feature-complete managed feature flag platform. It supports targeting rules, percentage rollouts, multivariate flags, experimentation, and real-time flag updates via server-sent events.
How It Works
# Python SDK example
import ldclient
from ldclient import Context
from ldclient.config import Config
# Initialize the client (once at application startup)
ldclient.set_config(Config("sdk-key-your-key-here"))
client = ldclient.get()
# Evaluate a flag for a specific user
user = Context.builder("user-123").set("plan", "enterprise").set("country", "US").build()
# Boolean flag
show_new_checkout = client.variation("new-checkout-flow", user, False)
# Multivariate flag (string, number, or JSON)
checkout_layout = client.variation("checkout-layout", user, "classic")
# Returns "classic", "streamlined", or "one-page" based on targeting rules
if show_new_checkout:
render_new_checkout(layout=checkout_layout)
else:
render_classic_checkout()
Targeting Rules
LaunchDarkly’s targeting system is where it earns its premium pricing. You can create rules like:
- Enable for users where
plan == "enterprise"ANDcountry in ["US", "CA"] - Enable for 25% of users where
plan == "pro" - Enable for specific user IDs (internal testing)
- Enable for users in a predefined segment (“beta-testers”)
# LaunchDarkly targeting configuration (conceptual, configured via UI/API)
{
"key": "new-checkout-flow",
"targets": [
{"variation": 0, "values": ["user-admin-1", "user-admin-2"]}
],
"rules": [
{
"clauses": [
{"attribute": "plan", "op": "in", "values": ["enterprise"]}
],
"variation": 0,
"rollout": null
},
{
"clauses": [
{"attribute": "plan", "op": "in", "values": ["pro"]}
],
"variation": null,
"rollout": {"variations": [{"variation": 0, "weight": 25000}, {"variation": 1, "weight": 75000}]}
}
],
"fallthrough": {"variation": 1},
"offVariation": 1
}
Pricing Reality
LaunchDarkly prices per seat and by Monthly Active Users (MAU). For a team of 10 engineers with 100K MAU, expect to pay $800-1,500/month depending on the plan. For large teams or high MAU counts, the bill can be significant. Whether that is worth it depends on how central feature flags are to your deployment strategy.
Unleash: The Open-Source Alternative
Unleash is the most mature open-source feature flag platform. You can self-host it for free or use their managed cloud offering. It supports most of the same concepts as LaunchDarkly — targeting, gradual rollouts, variants — with a simpler UI and fewer enterprise features.
Self-Hosting Unleash
# docker-compose.yml for Unleash
version: "3.9"
services:
unleash:
image: unleashorg/unleash-server:latest
ports:
- "4242:4242"
environment:
DATABASE_URL: "postgres://unleash:password@db:5432/unleash"
DATABASE_SSL: "false"
INIT_ADMIN_API_TOKENS: "*:*.unleash-admin-api-token"
depends_on:
db:
condition: service_healthy
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: unleash
POSTGRES_USER: unleash
POSTGRES_PASSWORD: password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U unleash"]
interval: 2s
timeout: 1s
retries: 10
volumes:
- unleash-data:/var/lib/postgresql/data
volumes:
unleash-data:
Using the Unleash SDK
# Python SDK example
from UnleashClient import UnleashClient
client = UnleashClient(
url="http://unleash:4242/api",
app_name="my-service",
custom_headers={"Authorization": "Bearer unleash-client-api-token"},
)
client.initialize_client()
# Boolean evaluation
if client.is_enabled("new-checkout-flow", context={"userId": "user-123"}):
render_new_checkout()
else:
render_classic_checkout()
# Variant evaluation (A/B testing)
variant = client.get_variant("checkout-layout", context={"userId": "user-123"})
# variant.name: "streamlined" | "classic" | "one-page"
# variant.payload: {"type": "json", "value": "{\"columns\": 2}"}
Unleash Activation Strategies
Unleash comes with built-in strategies that cover most use cases:
- Standard: Simple on/off toggle
- GradualRollout: Enable for a percentage of users (sticky based on user ID)
- UserIDs: Enable for specific user IDs
- IPs: Enable for specific IP addresses
- Hostnames: Enable for specific server hostnames
- Custom strategies: Write your own targeting logic
Rolling Your Own: When and How
Building your own feature flag system makes sense when you have specific requirements that off-the-shelf solutions do not meet, or when you have fewer than 20 flags and do not need targeting or gradual rollouts.
The Minimum Viable Feature Flag System
# A simple database-backed feature flag system
# flags table schema:
# CREATE TABLE feature_flags (
# key TEXT PRIMARY KEY,
# enabled BOOLEAN NOT NULL DEFAULT false,
# description TEXT,
# created_at TIMESTAMP DEFAULT NOW(),
# updated_at TIMESTAMP DEFAULT NOW()
# );
import asyncpg
from functools import lru_cache
import time
class FeatureFlags:
def __init__(self, pool: asyncpg.Pool):
self._pool = pool
self._cache = {}
self._cache_ttl = 30 # seconds
self._last_refresh = 0
async def _refresh_cache(self):
now = time.time()
if now - self._last_refresh < self._cache_ttl:
return
async with self._pool.acquire() as conn:
rows = await conn.fetch("SELECT key, enabled FROM feature_flags")
self._cache = {row["key"]: row["enabled"] for row in rows}
self._last_refresh = now
async def is_enabled(self, flag_key: str, default: bool = False) -> bool:
await self._refresh_cache()
return self._cache.get(flag_key, default)
async def set_flag(self, flag_key: str, enabled: bool, description: str = ""):
async with self._pool.acquire() as conn:
await conn.execute(
"""INSERT INTO feature_flags (key, enabled, description, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (key) DO UPDATE SET enabled = $2, updated_at = NOW()""",
flag_key, enabled, description,
)
# Invalidate cache
self._last_refresh = 0
# Usage
flags = FeatureFlags(db_pool)
@app.get("/checkout")
async def checkout():
if await flags.is_enabled("new-checkout-flow"):
return new_checkout()
return classic_checkout()
Adding Percentage Rollouts
# Extended schema:
# ALTER TABLE feature_flags ADD COLUMN rollout_percentage INT DEFAULT 100;
import hashlib
class FeatureFlagsWithRollout(FeatureFlags):
async def is_enabled_for_user(
self, flag_key: str, user_id: str, default: bool = False
) -> bool:
await self._refresh_cache()
flag = self._cache.get(flag_key)
if flag is None:
return default
if not flag["enabled"]:
return False
# Deterministic percentage check based on user_id + flag_key
# Same user always gets the same result for the same flag
hash_input = f"{flag_key}:{user_id}"
hash_value = int(hashlib.sha256(hash_input.encode()).hexdigest()[:8], 16)
bucket = hash_value % 100
return bucket < flag["rollout_percentage"]
This approach is deterministic — the same user always gets the same flag value, which is essential for consistent user experience and meaningful A/B testing.
The Decision Framework
| Criteria | LaunchDarkly | Unleash (self-hosted) | Roll Your Own |
|---|---|---|---|
| Setup time | Minutes | Hours | Days |
| Monthly cost (10-person team) | $800-1500 | $0 + infra | $0 + eng time |
| Targeting complexity | Advanced | Good | Basic (unless you build it) |
| Experimentation / A/B | Built-in | Basic variants | Build it yourself |
| Audit trail | Full | Full | Build it yourself |
| SDKs | 25+ languages | 15+ languages | Your language only |
| Real-time updates | SSE streaming | Polling + SSE | Polling (cache TTL) |
| Operational burden | None (managed) | You run Postgres + Unleash | You run everything |
My Recommendation
- Fewer than 10 flags, no targeting needed: Roll your own with a database table and a 30-second cache.
- 10-50 flags, basic targeting needed: Self-host Unleash. The operational cost is low (it is just a Node app and a Postgres database), and it covers most use cases.
- 50+ flags, experimentation, multiple teams: LaunchDarkly or Unleash Cloud. The cost is justified by the engineering time saved on building and maintaining the infrastructure.
The Flag Lifecycle: What Nobody Talks About
The hardest part of feature flags is not creating them — it is removing them. Flag debt accumulates silently and creates a combinatorial explosion of code paths that makes debugging increasingly difficult.
# The flag lifecycle should be enforced:
# 1. CREATE: Flag is created with an owner and an expiration date
# 2. ROLL OUT: Gradually increase percentage, monitor metrics
# 3. FULLY ENABLED: Flag is at 100% and verified stable
# 4. REMOVE: Delete the flag and its code paths
#
# Step 4 almost never happens without process enforcement.
# Add expiration tracking to your flag system:
# ALTER TABLE feature_flags ADD COLUMN expires_at TIMESTAMP;
# ALTER TABLE feature_flags ADD COLUMN owner TEXT;
# Automated alert for stale flags:
# SELECT key, owner, created_at FROM feature_flags
# WHERE enabled = true
# AND rollout_percentage = 100
# AND created_at < NOW() - INTERVAL '30 days'
# AND expires_at < NOW();
# This query finds flags that are fully rolled out, older than 30 days,
# and past their expiration date — candidates for removal.
Some teams add a linting rule that flags a TODO when a feature flag is created, or integrate with their issue tracker to auto-create cleanup tickets when a flag reaches 100% rollout. Whatever mechanism you choose, make flag cleanup a first-class part of your engineering process. A system with 500 active feature flags is not a well-flagged system — it is a system that has lost control of its code paths.
