System Design ·

Designing a Feature Flag System: Targeting Rules, Gradual Rollouts, and Real-Time Evaluation at Scale

A system design deep-dive into how feature flag platforms work internally. Covers the evaluation engine, data model for flags and segments, SDK architecture for low-latency evaluation, gradual rollout strategies, real-time propagation via SSE, auditability, and the tradeoffs between server-side and client-side evaluation.

Designing a Feature Flag System: Targeting Rules, Gradual Rollouts, and Real-Time Evaluation at Scale

Most teams start with a feature flag as a simple boolean in a config file. Then they need per-user targeting. Then percentage rollouts. Then environment isolation. Then real-time propagation without a deploy. By the time they are done, they have built half a feature flag platform without realizing it.

This article covers how feature flag systems actually work internally: the data model, the evaluation engine, rollout strategies, SDK architecture, and the propagation problem. The goal is to give you enough depth to build one or to reason clearly about the tradeoffs in existing solutions.

The Data Model

A flag is not just a boolean. The full model needs to support targeting rules, multiple environments, and versioning.

interface FeatureFlag {
  id: string;
  key: string;           // "checkout-v2-enabled", used in code
  name: string;
  description: string;
  environments: Record<string, EnvironmentConfig>;
  createdAt: Date;
  updatedAt: Date;
}

interface EnvironmentConfig {
  enabled: boolean;      // master kill-switch for this environment
  rules: TargetingRule[];
  defaultVariation: Variation;
  rolloutPercentage?: number;
}

interface TargetingRule {
  id: string;
  priority: number;      // lower = evaluated first
  conditions: Condition[];
  variation: Variation;
}

interface Condition {
  attribute: string;     // "userId", "plan", "country", "email"
  operator: ConditionOperator;
  values: string[];
}

type ConditionOperator =
  | "equals"
  | "not_equals"
  | "contains"
  | "starts_with"
  | "ends_with"
  | "in"
  | "not_in"
  | "matches_regex"
  | "semver_gte";

type Variation = { key: string; value: boolean | string | number | object };

The EnvironmentConfig separation is critical. A flag in production and in staging behaves independently: different rules, different rollout percentages, different default values. They share a key and a flag ID, but nothing else about their evaluation state.

Segments are reusable targeting groups. Instead of duplicating conditions across flags, you define a segment once and reference it.

interface Segment {
  id: string;
  key: string;           // "beta-users", "enterprise-plan"
  rules: SegmentRule[];
}

interface SegmentRule {
  conditions: Condition[];
}

// A condition can reference a segment:
interface SegmentCondition {
  attribute: "__segment__";
  operator: "in" | "not_in";
  values: string[];      // segment keys
}

This matters more than it seems at scale. Without segments, you end up with O(flags * user groups) condition duplication. With segments, you update one definition and all referencing flags pick it up on the next evaluation.

The Evaluation Engine

The evaluation engine takes a flag key, a user context, and an environment, and returns a variation. The order of operations is fixed.

interface EvaluationContext {
  userId: string;
  attributes: Record<string, string | number | boolean>;
}

interface EvaluationResult {
  flagKey: string;
  variation: Variation;
  reason: EvaluationReason;
  ruleId?: string;
}

type EvaluationReason =
  | "FLAG_DISABLED"
  | "TARGETING_RULE_MATCH"
  | "SEGMENT_MATCH"
  | "ROLLOUT"
  | "DEFAULT";

function evaluateFlag(
  flag: FeatureFlag,
  env: string,
  ctx: EvaluationContext,
  segments: Map<string, Segment>
): EvaluationResult {
  const config = flag.environments[env];

  // 1. Master kill-switch
  if (!config.enabled) {
    return { flagKey: flag.key, variation: config.defaultVariation, reason: "FLAG_DISABLED" };
  }

  // 2. Targeting rules in priority order
  for (const rule of config.rules.sort((a, b) => a.priority - b.priority)) {
    if (matchesRule(rule, ctx, segments)) {
      return {
        flagKey: flag.key,
        variation: rule.variation,
        reason: "TARGETING_RULE_MATCH",
        ruleId: rule.id,
      };
    }
  }

  // 3. Percentage rollout
  if (config.rolloutPercentage !== undefined) {
    const bucket = hashUserToBucket(ctx.userId, flag.id);
    if (bucket < config.rolloutPercentage) {
      return { flagKey: flag.key, variation: { key: "on", value: true }, reason: "ROLLOUT" };
    }
  }

  // 4. Default
  return { flagKey: flag.key, variation: config.defaultVariation, reason: "DEFAULT" };
}

The reason field is not optional. Without it, you cannot debug why a user is getting a specific variation in production.

Consistent Hashing for Rollouts

The bucket assignment for percentage rollouts must be deterministic. The same user must always land in the same bucket for the same flag, or users will flicker between variations across page loads or across server instances.

import { createHash } from "crypto";

function hashUserToBucket(userId: string, flagId: string): number {
  // Include flagId in the hash so different flags assign different buckets
  // to the same user. Without this, all flags roll out to the same users first.
  const input = `${flagId}:${userId}`;
  const hash = createHash("sha256").update(input).digest("hex");

  // Take the first 4 bytes as a 32-bit unsigned int, map to [0, 100)
  const value = parseInt(hash.slice(0, 8), 16);
  return (value / 0xffffffff) * 100;
}

The flagId salt is the non-obvious detail. Without it, a user at bucket 3 is always in the first 3% for every flag. Your gradual rollout always hits the same early adopter users rather than sampling broadly.

Rollout Strategies

Percentage-Based Rollout

The simplest rollout: increment the percentage over time. Bump from 1% to 5% to 25% to 100%, pausing at each step to observe error rates and latency.

interface RolloutSchedule {
  flagId: string;
  environment: string;
  steps: RolloutStep[];
  currentStep: number;
}

interface RolloutStep {
  percentage: number;
  holdDuration: number;  // milliseconds to hold before auto-advancing
  autoAdvance: boolean;
  conditions: RolloutGate[];
}

interface RolloutGate {
  metric: string;        // "error_rate", "p99_latency"
  threshold: number;
  operator: "lt" | "gt";
}

Auto-advance only applies if gates pass. If error rate spikes above threshold, the rollout halts and alerts. This is not something most teams build into their flag system initially, but it is what separates a toggle from a deployment primitive.

Ring-Based Rollout

Rings define named cohorts with explicit membership. The progression is ordered: ring 1 (internal employees), ring 2 (beta users), ring 3 (all users).

interface RingConfig {
  rings: Ring[];
}

interface Ring {
  name: string;
  priority: number;
  segmentKey: string;    // references a Segment
  variation: Variation;
}

// Evaluation inserts ring checks before percentage rollout:
function evaluateWithRings(
  flag: FeatureFlag,
  env: string,
  ctx: EvaluationContext,
  segments: Map<string, Segment>,
  rings: Ring[]
): EvaluationResult {
  // Sort rings by priority, check segment membership
  for (const ring of rings.sort((a, b) => a.priority - b.priority)) {
    const segment = segments.get(ring.segmentKey);
    if (segment && userInSegment(ctx, segment, segments)) {
      return {
        flagKey: flag.key,
        variation: ring.variation,
        reason: "TARGETING_RULE_MATCH",
        ruleId: `ring:${ring.name}`,
      };
    }
  }

  // Fall through to standard evaluation
  return evaluateFlag(flag, env, ctx, segments);
}

Rings make incident response faster. When ring 1 (your own team) shows a regression, you stop before ring 2 is touched. The blast radius is known before you start, not discovered afterward.

SDK Architecture

The SDK design determines evaluation latency. There are two architectures.

Remote evaluation: The SDK sends the user context to a flag service and gets back resolved variations. Simple to implement, but adds a network round-trip to every flag check. Acceptable for low-frequency checks (feature gates in server-side rendering), problematic for high-frequency checks (in-request decision trees).

Local evaluation: The SDK syncs a full copy of flag definitions and evaluates locally. Network cost is amortized across a background sync. Flag checks are in-memory lookups with sub-millisecond latency.

Most production SDKs use local evaluation with a background sync loop.

class FlagClient {
  private flags: Map<string, FeatureFlag> = new Map();
  private segments: Map<string, Segment> = new Map();
  private eventSource: EventSource | null = null;
  private environment: string;
  private sdkKey: string;

  constructor(sdkKey: string, environment: string) {
    this.sdkKey = sdkKey;
    this.environment = environment;
  }

  async initialize(): Promise<void> {
    // Fetch full flag state on init
    const state = await this.fetchFlagState();
    this.applyState(state);

    // Open SSE stream for real-time updates
    this.subscribeToUpdates();
  }

  evaluate(flagKey: string, ctx: EvaluationContext): EvaluationResult {
    const flag = this.flags.get(flagKey);
    if (!flag) {
      return {
        flagKey,
        variation: { key: "off", value: false },
        reason: "DEFAULT",
      };
    }
    return evaluateFlag(flag, this.environment, ctx, this.segments);
  }

  private subscribeToUpdates(): void {
    const url = `https://flags.example.com/stream?env=${this.environment}`;
    this.eventSource = new EventSource(url, {
      headers: { Authorization: `Bearer ${this.sdkKey}` },
    });

    this.eventSource.addEventListener("flag_updated", (event) => {
      const patch: FlagPatch = JSON.parse(event.data);
      this.applyPatch(patch);
    });

    this.eventSource.addEventListener("segment_updated", (event) => {
      const segment: Segment = JSON.parse(event.data);
      this.segments.set(segment.key, segment);
    });

    this.eventSource.onerror = () => {
      // Reconnect with exponential backoff
      setTimeout(() => this.subscribeToUpdates(), 5000);
    };
  }

  private applyPatch(patch: FlagPatch): void {
    if (patch.type === "upsert") {
      this.flags.set(patch.flag.key, patch.flag);
    } else if (patch.type === "delete") {
      this.flags.delete(patch.flagKey);
    }
  }

  private async fetchFlagState(): Promise<FlagState> {
    const res = await fetch(
      `https://flags.example.com/state?env=${this.environment}`,
      { headers: { Authorization: `Bearer ${this.sdkKey}` } }
    );
    return res.json();
  }

  private applyState(state: FlagState): void {
    for (const flag of state.flags) {
      this.flags.set(flag.key, flag);
    }
    for (const segment of state.segments) {
      this.segments.set(segment.key, segment);
    }
  }
}

interface FlagPatch {
  type: "upsert" | "delete";
  flag?: FeatureFlag;
  flagKey?: string;
}

interface FlagState {
  flags: FeatureFlag[];
  segments: Segment[];
  version: number;
}

Real-Time Propagation

When a flag changes in the admin UI, that change needs to reach all running SDK instances quickly. The choice is between polling, SSE, and WebSocket.

SSE works well for this use case: the flag service is the only writer, clients are readers, and the protocol handles reconnection automatically. WebSocket adds bidirectional overhead you do not need. Polling introduces lag proportional to interval length, and 5-second polling adds up quickly when you are in the middle of an incident and need a flag off immediately.

The propagation architecture has three components:

  1. A change event is written to a pub/sub channel (Redis, Kafka) when any flag is updated.
  2. Fan-out workers consume the channel and push SSE events to connected SDK clients, grouped by environment.
  3. SDK clients apply the patch to their local in-memory state.
// Server-side: SSE endpoint
import { Redis } from "ioredis";

async function streamFlags(
  req: Request,
  env: string,
  subscriber: Redis
): Promise<Response> {
  const { readable, writable } = new TransformStream();
  const writer = writable.getWriter();
  const encoder = new TextEncoder();

  const channel = `flags:updates:${env}`;
  await subscriber.subscribe(channel);

  subscriber.on("message", async (ch, message) => {
    if (ch === channel) {
      const data = `event: flag_updated\ndata: ${message}\n\n`;
      await writer.write(encoder.encode(data));
    }
  });

  req.signal.addEventListener("abort", async () => {
    await subscriber.unsubscribe(channel);
    await writer.close();
  });

  return new Response(readable, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

One subscriber per connection is expensive at scale. The production pattern is to fan out from a single Redis subscriber per server process to all connected SSE clients on that process. One Redis connection handles thousands of SSE clients.

Auditability

Every flag change must be logged with who changed it, what changed, and when. This is not just a nice-to-have: when something breaks in production at 2am, the first question is “what changed?”

interface AuditEvent {
  id: string;
  flagId: string;
  environment: string;
  actorId: string;
  actorEmail: string;
  eventType: AuditEventType;
  before: Partial<EnvironmentConfig> | null;
  after: Partial<EnvironmentConfig> | null;
  timestamp: Date;
  metadata: Record<string, string>;
}

type AuditEventType =
  | "FLAG_CREATED"
  | "FLAG_UPDATED"
  | "FLAG_DELETED"
  | "RULE_ADDED"
  | "RULE_REMOVED"
  | "ROLLOUT_PERCENTAGE_CHANGED"
  | "FLAG_ENABLED"
  | "FLAG_DISABLED";

async function recordAuditEvent(
  db: Database,
  event: Omit<AuditEvent, "id" | "timestamp">
): Promise<void> {
  await db.query(
    `INSERT INTO audit_events
     (flag_id, environment, actor_id, actor_email, event_type, before_state, after_state, metadata)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
    [
      event.flagId,
      event.environment,
      event.actorId,
      event.actorEmail,
      event.eventType,
      JSON.stringify(event.before),
      JSON.stringify(event.after),
      JSON.stringify(event.metadata),
    ]
  );
}

Store before and after as JSON snapshots, not a diff. Reconstructing state from a sequence of diffs is fragile. Snapshots are queryable and self-contained.

Tradeoffs

DimensionServer-Side EvaluationClient-Side Local Evaluation
LatencyNetwork round-trip per evaluationSub-millisecond, in-memory
Flag data exposureNone: context sent to server, variation returnedFull flag definitions visible to client
User context sensitivitySafe for PII: never leaves the serverPII in context is sent to evaluation service on bootstrap
Stale data riskAlways freshDepends on sync interval or SSE connection health
Operational complexitySimple SDK, complex serviceComplex SDK, simpler service
Best forServer-rendered apps, backend servicesHigh-frequency checks, edge evaluation

The flag data exposure row is what most teams miss. If you use local evaluation in a browser SDK, your flag rules (including segment definitions and user lists) are downloaded to every browser. Any user can open devtools and read your rollout strategy. For most flags this is fine. For flags that reveal future product direction or contain sensitive user group definitions, it is a real information leak.

A hybrid approach: use server-side evaluation for the initial page render (no stale data, no exposure), then hydrate a minimal flag state for client-side checks post-load. The client state includes only the resolved variations for the current user, not the raw rules.

Production Considerations

Bootstrap latency. The SDK initialization requires a full flag state fetch before any evaluation can happen. For server-side SDKs in serverless environments, this cold start cost compounds with function startup time. Mitigate with an in-process LRU cache keyed on environment, so the full fetch only happens on the first instance of each cold start wave, not on every invocation.

Version conflicts during deploys. When you deploy a new version of your application, old instances and new instances run simultaneously. A flag that expects a new context attribute (added in the new code) will silently use the default for old instances that do not send it. Design rules to be backwards-compatible: conditions on new attributes should default gracefully, not hard-fail.

Evaluation consistency across tiers. If your application evaluates a flag in the edge layer (for routing), the application layer (for rendering), and the API layer (for access control), all three must agree on the variation for the same user in the same request. This requires passing a resolved flag state from the edge inward, rather than re-evaluating independently at each tier. Independent re-evaluation at each tier introduces consistency windows.

Flag lifecycle cleanup. Flags accumulate. A system with hundreds of active flags has evaluation overhead, cognitive load, and stale targeting rules that no longer match any real user segment. Implement a staleness policy: flags not modified in 90 days and with 100% rollout are candidates for permanent code removal. Add flag expiry dates to the data model and alert on them.

Metrics and observability. Track evaluation counts per flag per variation, not just total evaluations. A flag that is 99% “off” and 1% “on” by evaluation count is a different operational state than one that is 50/50. Expose an evaluation endpoint in your SDK that logs variation, reason, and flag version so you can correlate production anomalies with specific flag states.

Segment cache consistency. If your evaluation engine caches segment membership to avoid recomputing on every evaluation, a segment update must invalidate those caches immediately. An SSE event for segment_updated must propagate with the same urgency as flag_updated.

Closing

A feature flag system is a deployment primitive, not just a developer convenience. The complexity lives in the evaluation engine and the propagation layer, not in the CRUD API around flag definitions. Get consistent hashing right, get SSE reconnection right, and separate flag data exposure from flag evaluation by evaluating server-side for sensitive rules. Everything else is operational discipline.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.