Feature Flags & A/B Testing
Tactical patterns for flag evaluation, progressive rollouts, controlled experiments, and flag lifecycle — the code-level companion to infrastructure-level canary deployments.
When to Activate
- Adding feature flag support to a new or existing service
- Designing a percentage-based or ring-based rollout
- Setting up A/B experiments or multivariate tests
- Choosing between LaunchDarkly, Unleash, and OpenFeature
- Implementing sticky bucketing or mutual exclusion across experiments
- Writing unit or integration tests for flag-gated code paths
- Auditing, cleaning up, or governing stale flags
Flag Types & Evaluation Context
Four flag types cover all use cases:
| Type | SDK method | Use for | |
|---|
boolean | getBooleanValue | On/off gates, kill switches | |
string | getStringValue | Variant selection (A/B/C), layout names | |
number | getNumberValue | Numeric config: timeout, batch size, rate limit | |
object | getObjectValue | Complex config blob, multi-key experiment payload | |
Evaluation Context
The context is the set of attributes the flag platform uses to apply targeting rules. Always include a stable user identifier.
typescript
// TypeScript — OpenFeature
import { OpenFeature, type EvaluationContext } from "@openfeature/server-sdk";
const ctx: EvaluationContext = {
targetingKey: user.id, // required: stable, not session-scoped
email: user.email,
orgId: user.orgId,
plan: user.plan, // "free" | "pro" | "enterprise"
country: request.geo.country,
appVersion: "2.4.1",
betaTester: user.betaTester, // custom boolean attribute
};
const client = OpenFeature.getClient("payments");
const enabled = await client.getBooleanValue("new-checkout", false, ctx);
const variant = await client.getStringValue("checkout-layout", "control", ctx);
const timeout = await client.getNumberValue("api-timeout-ms", 5000, ctx);
const config = await client.getObjectValue("rate-limit-config", { rpm: 100 }, ctx);
python
# Python — Unleash
context = {
"userId": str(user.id),
"properties": {
"orgId": str(user.org_id),
"plan": user.plan,
"country": request.geo.country,
},
}
enabled = client.is_enabled("new-checkout", context, fallback_function=lambda: False)
variant = client.get_variant("checkout-layout", context)
# variant = {"name": "control" | "v2-grid", "payload": {"type": "string", "value": "..."}}
go
// Go — LaunchDarkly
ctx := ldcontext.NewBuilder(user.ID).
Kind("user").
SetString("email", user.Email).
SetString("orgId", user.OrgID).
SetString("plan", user.Plan).
Build()
enabled, _ := ldClient.BoolVariation("new-checkout", ctx, false)
variant, _ := ldClient.StringVariation("checkout-layout", ctx, "control")
timeout, _ := ldClient.IntVariation("api-timeout-ms", ctx, 5000)
SDK Setup Patterns
LaunchDarkly
python
# Python
import ldclient
from ldclient.config import Config
from datetime import timedelta
ldclient.set_config(Config(
sdk_key="sdk-your-key",
stream=True, # streaming pushes changes; polling pulls on interval
poll_interval=30, # seconds, only used when stream=False
initial_reconnect_delay=timedelta(seconds=1),
))
ld = ldclient.get()
if not ld.is_initialized():
logger.warning("LaunchDarkly SDK not initialized — flag calls will return defaults")
go
// Go
import (
ld "github.com/launchdarkly/go-sdk/ldclient"
ldconf "github.com/launchdarkly/go-sdk/ldconfig"
)
config := ldconf.Config{
Events: ldconf.EventsConfig{Capacity: 10_000},
}
ldClient, err := ld.MakeCustomClient("sdk-your-key", config, 5*time.Second)
if err != nil {
log.Warnf("LaunchDarkly init failed: %v — using fallback defaults", err)
}
defer ldClient.Close()
Unleash
typescript
// TypeScript
import { initialize } from "unleash-client";
const unleash = initialize({
url: "https://unleash.example.com/api/",
appName: "frontend-app",
customHeaders: { Authorization: "Bearer <token>" },
refreshInterval: 15, // seconds between polls
metricsInterval: 60,
});
// Block until first sync so requests don't see stale state
await new Promise<void>((resolve) => unleash.on("synchronized", resolve));
python
# Python
from UnleashClient import UnleashClient
client = UnleashClient(
url="https://unleash.example.com/api",
app_name="backend-service",
custom_headers={"Authorization": "Bearer <token>"},
refresh_interval=15,
metrics_interval=60,
)
client.initialize_client()
OpenFeature — Provider Abstraction
OpenFeature decouples evaluation code from the vendor SDK. Swap providers without touching call sites.
typescript
import { OpenFeature } from "@openfeature/server-sdk";
import { LaunchDarklyProvider } from "@openfeature/launchdarkly-provider";
// import { UnleashProvider } from "@openfeature/unleash-provider";
// Change this one line to migrate vendor:
await OpenFeature.setProviderAndWait(new LaunchDarklyProvider("sdk-key"));
const client = OpenFeature.getClient("payments");
// All getBooleanValue / getStringValue calls remain identical after the swap
Caching & Offline Fallbacks
python
# GOOD: evaluate once per request, pass result down
async def handle_checkout(request):
ctx = build_context(request.user)
use_v2 = client.is_enabled("new-checkout", ctx) # one call
return render_checkout(request, v2=use_v2)
# BAD: evaluate inside a loop — one SDK call per item
async def handle_cart(request):
for item in request.cart.items:
if client.is_enabled("new-pricing", ctx): # N calls
item.price = calculate_new_price(item)
Always define safe defaults — the value returned when the SDK cannot reach the flag service:
typescript
// Default must be the safe/conservative behavior
const enabled = await client.getBooleanValue("new-checkout", false, ctx);
// ^^^^^ off by default
const rateLimit = await client.getNumberValue("rpm-limit", 100, ctx);
// ^^^ conservative default
Targeting & Rollout Patterns
| Pattern | Use Case | Bucketing | Remove after | |
|---|
| Dark launch | Code ships, invisible to users | — | Feature is stable | |
| Percentage rollout | Gradual ramp 1→5→25→100% | Sticky by user ID | 100% + 2-week soak | |
| Ring deployment | Internal → beta → enterprise → all | Group (org, plan) | All rings enabled | |
| Kill switch | Emergency disable without deploy | — | Keep indefinitely | |
| Permission flag | Role/plan-gated capability | Attribute exact match | Pricing restructure | |
| A/B experiment | Controlled hypothesis test | Sticky by user ID | Experiment concluded | |
Sticky Bucketing
Sticky bucketing ensures a user always gets the same variant — across sessions, devices, and services.
# GOOD: stable user ID as bucketing key
bucket = murmurhash(flag_key + user.id) % 100
# user 'alice' → always bucket 37 → always "treatment"
# BAD: session ID as bucketing key
bucket = murmurhash(flag_key + session_id) % 100
# 'alice' gets control on mobile, treatment on desktop, control after re-login
Multivariate Flags
python
variant = client.get_variant("checkout-layout", context)
match variant["name"]:
case "v2-grid":
return render_grid_layout()
case "v2-list":
return render_list_layout()
case _:
return render_control() # always handle the default / unknown case
Mutual Exclusion
Two experiments on the same surface contaminate each other's results. Use traffic partitioning:
Total traffic: 100%
├── Experiment A pool: 50% of users (checkout button color)
└── Experiment B pool: 50% of users (checkout button size)
No user is in both pools — interaction effects are eliminated.
Most flag platforms expose this as "experiment groups" or "mutex layers." For manual control, add an upstream assignment flag that routes users into pools before the experiment flags fire.
A/B Testing & Experimentation
Hypothesis Framing
Template: "If we [change], then [metric] will [direction] by [magnitude]
for [audience] because [reason]."
Good: "If we show inline address validation, then checkout completion
will increase by 8% for mobile users because form errors are caught earlier."
Bad: "The new checkout will be better." (no metric, no magnitude)
Bad: "Conversion will improve." (no baseline, no MDE)
Sample Size Calculation
python
import math
def min_sample_size(p_baseline: float, mde_absolute: float, alpha=0.05, power=0.80) -> int:
"""
Returns minimum users per variant.
p_baseline: current conversion rate (e.g. 0.12 for 12%)
mde_absolute: smallest effect worth detecting (e.g. 0.02 for +2pp)
"""
z_alpha = 1.96 # two-tailed, α=0.05
z_power = 0.84 # power=0.80
sigma = math.sqrt(p_baseline * (1 - p_baseline))
n = ((z_alpha + z_power) * sigma / mde_absolute) ** 2
return math.ceil(n)
# Baseline: 12% conversion. Want to detect +2pp lift.
n = min_sample_size(0.12, 0.02) # → ~4,100 per variant → ~8,200 total
Rule of thumb by effect size (binary metric, 80% power, α=0.05):
| Relative MDE | Approx. n per variant | |
|---|
| 10% | 1,500 | |
| 5% | 5,500 | |
| 2% | 34,000 | |
| 1% | 130,000 | |
Statistical Significance & the Peeking Problem
Target: p < 0.05 (two-tailed), power ≥ 0.80
Peeking problem: checking significance daily and stopping at first p < 0.05
inflates false positive rate from 5% to ~40% over 20 checks.
Fix options:
1. Pre-register sample size → evaluate ONCE at that threshold (no peeking)
2. Sequential testing (SPRT) — designed for continuous monitoring
3. Bayesian testing — computes probability of being best, handles early stopping
Holdout Groups
python
HOLDOUT_PCT = 5 # 5% of users never see any experiment
def in_holdout(user_id: str) -> bool:
bucket = mmh3.hash(f"global-holdout:{user_id}", signed=False) % 100
return bucket < HOLDOUT_PCT
# Check before any experiment assignment
if in_holdout(user.id):
serve_all_controls(user)
else:
assign_experiments(user)
Holdout groups measure the cumulative long-term effect of all experiments combined. Without one, you can't distinguish "we shipped 10 wins" from "we got lucky with 3 wins and 7 neutral experiments."
Metrics Hierarchy
Primary: the one metric the experiment is designed to move
→ checkout_completion_rate
Guardrail: metrics that must NOT degrade, regardless of primary movement
→ api_p95_latency ≤ 400ms, error_rate ≤ 0.5%, revenue_per_user
Secondary: directional signals, inform future work, never the decision basis
→ add_to_cart_rate, session_duration
An experiment is a win only if: primary improved AND all guardrails held. A guardrail violation kills the experiment — even with a strong primary improvement.
Metric Event Emission
typescript
// Emit metric events alongside flag evaluation
const { value: inExperiment } = await client.getBooleanDetails(
"checkout-v2-exp", false, ctx
);
trackEvent("experiment.assigned", {
experiment: "checkout-v2-exp",
variant: inExperiment ? "treatment" : "control",
userId: ctx.targetingKey,
});
// On conversion — linked by userId to calculate per-variant rate
trackEvent("checkout.completed", { userId: ctx.targetingKey, revenue: order.total });
Observability for Flags
Structured Logging
python
# Python — log every flag evaluation
import structlog, time
log = structlog.get_logger()