Language:English VersionChinese Version

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" AND country 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.

By Michael Sun

Founder and Editor-in-Chief of NovVista. Software engineer with hands-on experience in cloud infrastructure, full-stack development, and DevOps. Writes about AI tools, developer workflows, server architecture, and the practical side of technology. Based in China.

Leave a Reply

Your email address will not be published. Required fields are marked *