Feature Flags Architecture for Progressive Delivery
Feature flags architecture enables teams to decouple deployment from release. You deploy code to production continuously, but control which users see new features through configuration rather than code branches. This pattern — called progressive delivery — lets you test features with small user groups, roll back instantly without redeploying, and run A/B experiments safely. The mental shift is significant: a deploy stops being a scary all-or-nothing event and becomes a routine, low-risk act, because the moment a feature misbehaves you flip a flag rather than scramble for a rollback pipeline.
This guide covers the architectural patterns for building a robust feature flag system, from simple boolean toggles to sophisticated targeting rules with percentage rollouts. You will learn the common pitfalls, including the technical debt trap that catches most teams. We will also be honest about where flags hurt more than they help, because every flag you add is a permanent fork in your control flow until someone deletes it.
Types of Feature Flags
Not all feature flags serve the same purpose. Understanding the types helps you set appropriate lifetimes and ownership:
Flag Types and Lifetimes:
┌──────────────────┬──────────────┬───────────────────────────┐
│ Type │ Lifetime │ Purpose │
├──────────────────┼──────────────┼───────────────────────────┤
│ Release Toggle │ Days-Weeks │ Progressive feature rollout│
│ Experiment │ Weeks-Months │ A/B testing, metrics │
│ Ops Toggle │ Permanent │ Kill switches, circuit │
│ │ │ breakers │
│ Permission │ Permanent │ Entitlement, plan-based │
│ │ │ access │
│ Dev Toggle │ Hours-Days │ WIP features in trunk │
└──────────────────┴──────────────┴───────────────────────────┘
Core Architecture
// Feature flag service architecture
interface FeatureFlag {
key: string;
type: "release" | "experiment" | "ops" | "permission";
enabled: boolean;
rules: TargetingRule[];
defaultValue: boolean | string | number;
metadata: {
owner: string;
createdAt: string;
expiresAt?: string; // Force cleanup for release flags
description: string;
jiraTicket?: string;
};
}
interface TargetingRule {
priority: number;
conditions: Condition[]; // AND logic within a rule
percentage?: number; // Percentage rollout
value: boolean | string | number;
}
interface Condition {
attribute: string; // user.plan, user.country, etc.
operator: "eq" | "neq" | "in" | "contains" | "gte" | "lte";
values: string[];
}
// Example: Progressive rollout with targeting
const newCheckoutFlag: FeatureFlag = {
key: "new-checkout-flow",
type: "release",
enabled: true,
defaultValue: false,
rules: [
// Rule 1: Always on for internal team
{
priority: 1,
conditions: [{ attribute: "user.email", operator: "contains", values: ["@mycompany.com"] }],
value: true,
},
// Rule 2: 25% rollout for US users
{
priority: 2,
conditions: [{ attribute: "user.country", operator: "eq", values: ["US"] }],
percentage: 25,
value: true,
},
// Rule 3: Beta users always get it
{
priority: 3,
conditions: [{ attribute: "user.plan", operator: "eq", values: ["beta"] }],
value: true,
},
],
metadata: {
owner: "checkout-team",
createdAt: "2026-03-01",
expiresAt: "2026-04-15",
description: "New checkout flow with one-click purchase",
jiraTicket: "CHECKOUT-1234",
},
};
Evaluation Engine
class FlagEvaluator {
evaluate(flag: FeatureFlag, context: UserContext): FlagResult {
if (!flag.enabled) {
return { value: flag.defaultValue, reason: "flag_disabled" };
}
// Evaluate rules in priority order
const sortedRules = [...flag.rules].sort((a, b) => a.priority - b.priority);
for (const rule of sortedRules) {
if (this.matchesConditions(rule.conditions, context)) {
// Handle percentage rollout
if (rule.percentage !== undefined) {
const hash = this.consistentHash(flag.key, context.userId);
if (hash <= rule.percentage) {
return { value: rule.value, reason: "rule_match_percentage" };
}
continue; // Percentage didn't match, try next rule
}
return { value: rule.value, reason: "rule_match" };
}
}
return { value: flag.defaultValue, reason: "default" };
}
// Consistent hashing ensures same user always gets same result
private consistentHash(flagKey: string, userId: string): number {
const input = flagKey + userId;
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 100;
}
}
Why Consistent Hashing Is Non-Negotiable
The single most important detail in that evaluator is the consistent hash, and it is worth understanding why a naive implementation fails. If you bucketed users with Math.random(), every page load would re-roll the dice — a user might see the new checkout, refresh, and lose it, producing a flickering experience and worthless experiment data. Hashing flagKey + userId guarantees the same user always lands in the same bucket, so a 25% rollout is stable across sessions. Crucially, the flag key is part of the hash input. Without it, every flag set at 25% would select the same 25% of users, and your "independent" experiments would be silently correlated. Including the key decorrelates the buckets so each rollout samples a fresh slice of the population.
One honest caveat: the bit-shift hash shown above is fine for in-process bucketing, but it is not cryptographically uniform. If you need rollouts that are statistically clean across millions of users, prefer a well-distributed hash such as MurmurHash3 or xxHash, which is what mature flag platforms use under the hood.
Server-Side Versus Client-Side Evaluation
Where you evaluate flags has real security and performance consequences, and teams often pick wrong. Client-side evaluation — shipping flag rules to the browser — is convenient but leaks your targeting logic and any unreleased feature names to anyone who opens dev tools. It is also vulnerable to tampering, since a user can flip their own flags. Server-side evaluation keeps rules and unreleased code paths private, which matters for anything competitively sensitive or security-related. The trade-off is latency: the server must fetch flag state, so production systems cache the full ruleset in memory and refresh it via streaming updates or short polling rather than calling out on every request.
// Server-side evaluation with an in-memory cache and safe fallback.
// The flag store streams updates; we never block a request on the network.
public class FeatureClient {
private final AtomicReference<Map<String, FeatureFlag>> cache;
public boolean isEnabled(String key, UserContext ctx) {
FeatureFlag flag = cache.get().get(key);
if (flag == null) {
// Fail CLOSED for risky features, OPEN for cosmetic ones.
log.warn("flag {} not found, using safe default", key);
return false;
}
try {
return evaluator.evaluate(flag, ctx).value();
} catch (Exception e) {
// A bug in flag evaluation must never take down the request.
log.error("flag eval failed for {}, defaulting", key, e);
return false;
}
}
}
Notice the two fallbacks. A missing flag and an evaluation error both resolve to a safe default rather than throwing. This is a core resilience principle: the flag system sits in the hot path of every request, so it must degrade gracefully. A failure to read a flag should never be more disruptive than the feature the flag controls. Decide deliberately whether each flag fails open (the safe default is "feature on") or closed (the safe default is "feature off") — payment and security flags almost always fail closed.
Kill Switches for Production Safety
// Ops toggle pattern — instant feature disable
class PaymentService {
async processPayment(order: Order): Promise {
// Kill switch: instantly disable new payment provider
if (!flags.isEnabled("payment-provider-stripe-v2", order.user)) {
return this.legacyPaymentFlow(order);
}
try {
return await this.stripeV2Payment(order);
} catch (error) {
// Auto-disable on error threshold
metrics.increment("payment.stripe_v2.error");
if (metrics.errorRate("payment.stripe_v2") > 0.05) {
await flags.disable("payment-provider-stripe-v2");
alerting.critical("Stripe V2 auto-disabled: error rate > 5%");
}
return this.legacyPaymentFlow(order);
}
}
}
Managing Feature Flag Debt
The #1 problem with feature flags architecture is technical debt. Teams add flags but never remove them. After a year, you have hundreds of stale flags creating dead code paths and making the codebase harder to understand. Worse, every live flag doubles the number of code paths that need testing — ten independent boolean flags imply over a thousand theoretical combinations, and your test suite covers almost none of them. Stale flags are therefore not just clutter; they are latent bugs waiting for an unexpected combination of states to trigger them.
// Automated flag cleanup pipeline
class FlagCleanupService {
async findStaleTags(): Promise {
const flags = await this.flagStore.getAll();
const stale: StaleFlag[] = [];
for (const flag of flags) {
// Release flags older than expiry date
if (flag.type === "release" && flag.metadata.expiresAt) {
if (new Date(flag.metadata.expiresAt) < new Date()) {
stale.push({ flag, reason: "expired" });
}
}
// Flags enabled for 100% for over 30 days
if (this.isFullyRolledOut(flag) && this.daysActive(flag) > 30) {
stale.push({ flag, reason: "fully_rolled_out" });
}
// Flags with no evaluations in 14 days
const lastEval = await this.metrics.lastEvaluation(flag.key);
if (!lastEval || daysSince(lastEval) > 14) {
stale.push({ flag, reason: "unused" });
}
}
return stale;
}
}
The most effective cleanup tactic is to make staleness impossible to ignore. Require an expiresAt on every release flag at creation time, then wire the cleanup service into CI so an expired flag fails the build or files a ticket automatically. Some teams go further and treat a fully-rolled-out flag as a lint error, forcing the engineer to either delete the flag and inline the winning code path or justify keeping it. Because the social pressure to "leave it for now" is strong, automation that creates work items is far more reliable than relying on good intentions.
When NOT to Use Feature Flags
Feature flags add complexity to your codebase and testing matrix. Therefore, skip them for: database schema changes (use migrations), one-time data backfills, changes that must be atomic across all users, or features so small they can be reviewed and rolled back via git revert. As a result, reserve flags for user-facing features with meaningful rollout risk. There is also a deeper architectural smell to watch for: if you find yourself flagging deep internal behavior that no product manager would ever toggle, you may be using flags to paper over a missing abstraction. Flags belong at meaningful product boundaries, not scattered through low-level utilities where they become permanent, untestable branches.
Key Takeaways
Feature flags enable progressive delivery — the safest way to ship features to production. Categorize flags by type, set expiration dates on release flags, and automate cleanup. Use consistent hashing for stable rollouts, evaluate server-side for anything sensitive, and design every flag check to fail to a safe default. For the broader delivery picture, see our guides on platform engineering and GitOps with ArgoCD. The architecture pays for itself with the first production incident you avoid through instant rollback.
Related Reading
- Platform Engineering & Developer Portal
- Modular Monolith vs Microservices
- GitOps with ArgoCD & Crossplane
External Resources
In conclusion, a disciplined feature flags architecture is an essential topic for modern software development. By applying the patterns and practices covered in this guide — typed flags with expiry dates, consistent-hash rollouts, server-side evaluation, safe-default failure modes, and automated cleanup — you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.