Design a feature flag service
Open in the interactive app: practice, quiz, mock interview →In one minute
- Evaluate in the process that needs the answer. The SDK keeps the whole ruleset in memory and evaluates a flag as a pure function of
(ruleset, context). A flag check costs well under a microsecond and has no network call, so the flag service can never sit on the request path (Unleash architecture). - Stream versioned changes and resync cheaply. A long-lived connection pushes each change in about a second, every message carries a monotonically increasing version, and a periodic poll repairs anything the stream dropped.
- Hash for stable percentage rollouts.
hash(flag salt, user id) mod 10000gives each user a fixed bucket per flag. Raising 5% to 20% only adds users, nobody flips back, and a per-flag salt keeps two flags from picking the same people. - Fail static. When the control plane is down the SDK keeps serving the last good ruleset, then a baked-in default. A flag outage must never become an application outage.
- Treat every change as a production deploy. Validate it, attribute it in an audit log, canary it by environment, and make the kill switch the highest-precedence rule so one click wins over everything else.
Requirements
Functional
- Boolean and multivariate flags per environment (dev, staging, prod), with a default and an off value.
- Targeting rules on user attributes (country, plan, app version), user and segment allow-lists, and ordered rule evaluation.
- Percentage rollouts that are sticky per user and can be ramped up and down.
- A kill switch per flag that overrides every other rule.
- An audit log of who changed what, when, and why, with the ability to roll back to a version.
- SDKs for server languages that evaluate locally. Browsers and phones get pre-evaluated results.
Non-functional
- Evaluation under 1 microsecond and zero network calls at evaluation time.
- Propagation: p99 under 5 s from publish to every connected SDK, under 2 s for kill switches.
- The control plane can be fully down and applications keep working indefinitely.
- 99.99% availability of the read path, which is allowed to be stale but never wrong about a published version.
- No user attributes leave the application unless the flag is evaluated server-side for a browser or phone.
Out of scope
- Experiment statistics (p-values, sequential testing). We emit exposure events and stop there.
- A full configuration service for arbitrary JSON. We cover flags with rules.
Back-of-envelope
Assume 50,000 server processes, 2,000 projects and 5,000 flags per environment.
- Evaluations: 50,000 processes × 2,000 checks/s = 100 million checks/s in aggregate. All local, so the service sees none of it.
- Ruleset size: 5,000 flags × 600 B of rules = 3 MB per environment, around 400 KB gzipped. A full snapshot to 50,000 SDKs is 20 GB gzipped, which is why a flag change travels as a delta of roughly 1 KB: 50 MB of fan-out per change instead of 20 GB.
- Changes: 2,000 flag edits a day, average 0.02 per second, bursts of 10 per second during a release. Trivial for a database, which tells you the hard parts are fan-out and safety, not throughput.
- Connections: 50,000 SDK streams. A relay node holds about 20,000 idle HTTP streams comfortably, so 5 to 10 relays per region.
- Audit storage: 2,000 entries/day × 2 KB = 4 MB/day, about 7 GB over 5 years. Store diffs, not snapshots: a 3 MB snapshot per edit would be 6 GB/day.
- Exposure telemetry: raw events are impossible at 100 million/s. Each SDK sends aggregated counts per flag and variation every minute: about 600 counters × 30 B = 18 KB per SDK per minute, 15 MB/s overall.
API
# Control plane (humans and automation)
PUT /v1/projects/{p}/envs/{e}/flags/{key} # body: flag definition, header If-Match: <flag version>
POST /v1/projects/{p}/envs/{e}/flags/{key}/kill # body: {"on": true, "reason": "INC-4411"}
GET /v1/projects/{p}/envs/{e}/audit?flag=checkout&limit=50
POST /v1/projects/{p}/envs/{e}/rollback # body: {"to_version": 4812}
# Data plane (SDKs)
GET /sdk/stream?since=4812 # text/event-stream: put (snapshot) and patch (delta) events
GET /sdk/ruleset # full snapshot, ETag = version, served from CDN or object store
POST /sdk/events # aggregated exposure counts{ "key": "checkout-v2", "salt": "k3x9", "enabled": true, "kill": false,
"rules": [ { "attribute": "country", "values": ["ID", "SG"], "variation": "on" } ],
"rollout_bp": 2000, "rollout_variation": "on",
"fallthrough": "off", "off_variation": "off" }rollout_bp is in basis points (2000 = 20%). A stream event looks like event: patch / id: 4813 / data: {"upsert":[...],"delete":[...]}; the id is the ruleset version.
Data model
flagskeyed by(project, env, flag_key): definition JSON, per-flagversion,salt(random, fixed at creation),archived_at. Strong consistency matters here, so a single relational primary per region is enough at 2,000 writes a day.ruleset_versionskeyed by(project, env, version): the monotonically increasing environment version, plus the changed keys. The current snapshot is materialised in object storage underrulesets/{project}/{env}/latest.json.gz.audit_logappend-only, keyed by(project, env, seq): actor, time, flag, before and after diff, free-text reason, and the resulting environment version. Never updated, never deleted inside the retention window.segmentskeyed by(project, env, segment_key): a reusable list of rules or user ids, referenced by flags. Large id lists are stored as a sorted array or Bloom filter so the ruleset stays small.sdk_keyshashed, scoped to one environment. Server keys can read rules; client keys can only read pre-evaluated results.
The environment version, not the flag version, is the unit the SDK tracks. A single integer lets an SDK say "I have 4812" and a relay answer with exactly the changes since.
High-level design
flowchart LR
UI["Admin UI / API"] --> CP["Control plane"]
CP --> DB[("Flags + audit DB")]
CP --> PUB["Publisher"]
PUB --> OBJ[("Snapshot in object storage")]
PUB --> RELAY["Stream relays"]
OBJ --> CDN["CDN"]
RELAY --> SDK["SDK in app: ruleset in memory"]
CDN --> SDK
SDK --> EVAL["evaluate(flag, context)"]
SDK --> EV["Aggregated exposure counts"]
EV --> TEL["Telemetry pipeline"]
BOOT["Bootstrap file on disk"] --> SDKWrite path. An engineer saves a flag. The control plane checks permissions, validates the definition (known operators, rollout between 0 and 10000, referenced segments exist), then in one transaction updates flags, appends the audit row and increments the environment version. The publisher writes the new snapshot to object storage first and then emits a patch with that version to the relays, so a client that misses the patch can always fetch a snapshot at least as new.
Read path. At startup an SDK loads the bootstrap file if present, then fetches the snapshot from the CDN, then opens the stream with since=<version>. Evaluation never touches the network. A background poll every 30 to 60 seconds sends If-None-Match: <version> so a missed event is repaired within a minute even when the stream silently stalled.
Deep dives
1. Getting changes to 50,000 processes
- Polling only. Each SDK fetches the snapshot on an interval. It is simple, cache-friendly and survives proxies, and Unleash's backend SDKs work this way with a delay of a few seconds (Unleash docs). The catch: a 1 s interval across 50,000 SDKs is 50,000 requests/s for a mostly unchanged answer, and a kill switch still waits a full interval.
- Streaming. A server-sent events connection pushes a patch the moment it is published. The SSE standard gives you automatic reconnection and a
Last-Event-IDheader so the server can resume from the last version seen (WHATWG HTML standard). The catch is that proxies idle-timeout streams, and a half-open connection looks healthy while delivering nothing. - Choice: stream plus poll. Use the stream for speed and a slow conditional poll as the correctness backstop. Every message carries the environment version, and the SDK applies a message only if its version is higher than the current one. Duplicates and reordering become harmless, and on a gap the SDK fetches the full snapshot. Envoy's xDS protocol solves the same problem with versioned resources and an explicit ACK or NACK, so a bad update is rejected without breaking the running config (Envoy xDS).
Fan-out is the real scale problem: relays hold the 50,000 connections. After a relay restarts, 20,000 SDKs reconnect at once, so each waits a random delay (capped exponential backoff with jitter), because synchronised retries turn one failure into a cascade (Google SRE book).
2. Stable percentage rollouts and targeting
A rollout must satisfy three properties: the same user always gets the same answer, increasing the percentage only adds users, and different flags select statistically independent groups.
- Random per request fails the first property.
- Store an assignment per user satisfies it but needs a lookup on every evaluation.
- Hash. Compute
bucket = hash(flag_key + "." + salt + "." + user_id) mod 10000. The user is in the rollout ifbucket < rollout_bp. Raisingrollout_bpfrom 500 to 2000 only adds buckets 500 to 1999, so nobody drops out. Unleash documents the same idea: a normalised MurmurHash of the user id gives consistent and random distribution (Unleash activation strategies).
The salt matters. If every flag hashed only the user id, the same 5% of users would be the guinea pigs for every experiment. With a per-flag salt each flag draws an independent 5%, and changing a salt on purpose reshuffles everyone, which restarts an experiment cleanly.
Multivariate flags split the 10,000 buckets into consecutive ranges, for example A 0 to 3333, B 3334 to 6666, C the rest. Targeting rules run before the rollout and are evaluated in order, first match wins; a user-id allow-list is just a rule. The result is returned with a reason (KILLED, OFF, RULE:2, ROLLOUT, FALLTHROUGH, MISSING_FLAG), which is exactly what OpenFeature's resolution details carry, so debugging "why did this user get that?" never needs a guess (OpenFeature spec).
The unit you hash is a product decision: user id for logged-in features, device id for anonymous traffic, organisation id when a whole tenant must see one experience. A context with no id cannot be bucketed, so the fallthrough must be safe.
3. Fail-static and safe changes
The control plane will be down sometimes, so every layer needs a defined behaviour:
- Control plane down. SDKs and relays keep the last version; nothing running changes.
- Relay or stream down. The SDK polls the CDN snapshot, then falls back to its in-memory copy.
- Cold start with everything down. Load the bootstrap file from the image or local disk, else use the coded default at the call site.
This is static stability: the system keeps doing what it was doing when a dependency is impaired, though it stops seeing updates (AWS Builders' Library). Each call site chooses its code default deliberately, and it should be the safe, old behaviour.
The harder half is making a bad change survivable, because flags change production behaviour everywhere in seconds. Facebook's configuration system treats such changes as code: reviewed, validated, canaried and distributed through one pipeline, with thousands of changes a day and trillions of checks (SOSP 2015 paper). Apply that here:
- Validate on write, including a dry-run evaluation against sample contexts.
- Roll out rulesets by environment and cell, with automatic halt if the error rate moves.
- Each SDK validates before swapping. Parse into a fresh object, check it, then swap one pointer. A malformed payload is dropped and the old ruleset keeps serving.
- Kill switch has top precedence and a short path: checked first, and pushed ahead of queued patches.
- Retire flags. Long-lived release flags become dead code with live switches, so give each a removal date (Fowler).
Down the stack
Layer 1The contract
- Evaluation is deterministic: the same ruleset and context give the same
(value, reason)in every SDK and language. That needs a hash with a fixed specification, not a language's built-in hash. - Freshness is bounded: a connected SDK reflects a published version within 5 s at p99. A disconnected one serves its last version and reports
stale. - Monotonic versions: a process never moves backwards to an older version, and a rollback is a new, higher version whose content equals an old one.
- Never throws: any internal error returns the call-site default with an
ERRORreason. - Audit completeness: every published version has exactly one audit entry naming an actor.
Layer 2The mechanism
- Stream framing: SSE lines
event: patch,id: 4813,data: {...}then a blank line, plus a: keepalivecomment every 15 to 30 s so idle proxies stay open. - Resume: on reconnect the SDK sends
Last-Event-ID: 4813. The relay replays from a ring buffer of the last few thousand patches, or sends aputevent with a fresh snapshot when the gap is too large. - Snapshot endpoint:
ETagequal to the version, shortmax-age, served from a CDN so a fleet restart hits storage once per edge. - Hash: 32-bit FNV-1a or MurmurHash3 over UTF-8 bytes of
flag_key.salt.unit_id, taken mod 10000. FNV-1a is a loop of XOR and multiply by0x01000193; Murmur3 distributes better for very small rollouts. - Swap: the SDK holds one immutable
Rulesetobject behind an atomic pointer. Evaluation reads the pointer once and uses that snapshot throughout, so a patch arriving mid-evaluation can never produce a half-old, half-new answer. - Pre-evaluated clients: for browsers, an edge function evaluates all flags for one context and returns a small JSON map, so rules never reach untrusted code.
Layer 3The machine
- Evaluation cost: a hashed lookup of the flag (about 20 to 50 ns), a short rule loop over strings, and a 20-byte FNV hash (about 20 ns) put a check around 100 to 300 ns, all on data that stays in L1 and L2 cache.
- Parse cost and GC: decoding 3 MB of JSON takes roughly 10 to 30 ms and allocates tens of MB. Do it only when the version changes, off the request path, not on every poll.
- Network: a 1 KB patch is one TCP segment (MSS about 1,460 B), so delivery is one relay-to-SDK half round trip, a few milliseconds in-region. A fresh TLS handshake costs 1 to 2 round trips, so SDKs keep the stream open.
- Relay sockets: 20,000 streams per node means 20,000 file descriptors, and the kernel's
epoll_waitloop wakes on writes only. - Disk: the bootstrap file is written with write-to-temp,
fsync, thenrenameso a crash leaves either the old or the new file, never half of one.
Why it's built this way
- A flag check is on every request, and a network round trip costs 0.5 ms in-region against 0.2 microseconds in memory, so evaluate locally.
- Local evaluation means every process holds a copy, so copies drift. Version numbers and a poll backstop make drift bounded and detectable.
- A pure function of ruleset and context lets the hash be the assignment, so no per-user state is needed and the answer is reproducible on any machine.
- A flag change can alter behaviour everywhere in seconds, so safety (validation, staged rollout, audit, kill switch) is more important than raw speed.
- The control plane is the least available part, because it is the one humans edit. Therefore the data plane must not depend on it to keep running.
Failure modes
- Control plane outage. Nothing changes for running apps. Editing is blocked; the kill switch needs a break-glass path (a second API key to the publisher or a config in object storage) so an operator can disable a feature even when the UI is down.
- Region outage. SDKs fail over to relays in another region or keep serving their cache.
- Reconnect storm at 10x. A relay restart or network blip makes tens of thousands of SDKs reconnect together. Mitigate with full jitter, a connection cap per relay with 503 plus
Retry-After, and snapshots from the CDN rather than the origin. - Hot flag. A flag with a huge segment (millions of user ids) bloats every patch. Keep such lists out of the ruleset: evaluate server-side, or use a compact Bloom filter or a lookup the app already has.
- Poison ruleset. A bad rule crashes or mis-serves everywhere at once. SDK-side validation and swap-after-parse, canaried publishing, and rollback as a version bump.
- Hash drift between SDKs. If the Go and Java SDKs hash differently, one user sees different values on different services. Ship shared test vectors (
bucket("checkout","s1","user-1") == 2468) in every SDK's test suite.
In the wild
- Google Cloud, 2025: a global change with no flag. On 12 June, Service Control crashed worldwide when a policy row with blank fields hit a quota-check code path added on 29 May that had no feature flag and no error handling. A "red button" kill of the bad path took about 40 minutes to roll out, but us-central1 needed far longer because restarting tasks overloaded Spanner without randomized backoff. Google committed to feature-flagging such code, failing open and replicating global data incrementally (Google Cloud incident report).
- Knight Capital, 2012: a reused flag and a half-finished deploy. New code was copied to only 7 of 8 servers, and it reused a flag that once enabled the retired "Power Peg" logic. The eighth server ran the old logic, sent about 4 million executions in 154 stocks in roughly 45 minutes and lost over $460 million; removing the new code from the other seven made all eight run it. The SEC cited the missing deployment review and written procedures (SEC order).
- PostHog, 2025: the flag service as the outage. Between 21 and 30 October the PostHog feature flags service had four incidents totalling about 12 hours. On 24 October a new IP-keyed rate limiter saw only the load balancer address, shared one bucket, and about 97% of flag evaluations returned 429 for 72 minutes. This is a service that clients call on every request, so its outage became its customers' outage (PostHog post-mortem).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Local evaluation of a streamed ruleset | Sub-microsecond, survives control-plane outage, attributes stay private | Ruleset in every process, staleness, SDK per language | Backend services, the default |
| Remote evaluation API per check | One implementation, rules never leave the server | Latency and availability on every request, per-check cost | Browsers and phones, via an edge cache |
| Hash-based bucketing | No state, sticky, monotonic | Cannot move one user without a rule, unit choice matters | Percentage rollouts |
| Fail-static to last good | App unaffected by control-plane outage | New kill switches do not arrive | Always, with a break-glass path for urgent changes |
Leadership lens
- Build first: local evaluation, a snapshot endpoint, one hash with shared test vectors, kill switch and audit log. Streaming and relays come second, targeting UI third. Or buy: the SDK contract matters more than the vendor, so put OpenFeature in front of whichever one you pick (OpenFeature).
- Ownership: a platform team owns the service, SDKs and on-call. Product teams own flag lifecycle: each flag has an owner, a type (release, experiment, ops, permission) and an expiry. Fowler's categories are a useful vocabulary for this (Fowler).
- Review questions: what does this flag default to if the service is gone? who can flip it in production and how is that audited? what is the removal date? how does the kill switch reach the process in under 5 seconds?
- Cost drivers: mostly SDK maintenance across languages and exposure telemetry volume, not compute. Flag debt (stale flags, combinatorial test matrices) is the largest organisational cost.
- Separation of change from deploy: decoupling launch from release is the reason to run this at all, and it is the pattern the SRE workbook recommends for reducing release risk (SRE Workbook).
Check yourself
Why must the hash include a per-flag salt, and what happens if you change the salt later?
Show answer
Without a salt, the same low-numbered buckets are chosen by every flag, so the same users receive every experiment and results are correlated. Changing the salt moves every user to a new random bucket, which restarts the experiment but also reshuffles who sees the feature.
A relay restarts and 20,000 SDKs reconnect at the same instant. What goes wrong and what do you do?
Show answer
The relay and snapshot origin face a synchronised burst that can trip its limits and fail again in lockstep. SDKs reconnect after a randomised exponential backoff, resume with
Last-Event-ID, and fetch snapshots from a CDN, while relays cap connections and returnRetry-After.The control plane has been down for six hours and a feature is misbehaving. What can an operator still do?
Show answer
Apps keep serving the last ruleset, so nothing breaks, but the normal UI path is dead. A break-glass path writes the kill switch directly to the publisher or object store snapshot, which relays and the polling backstop then deliver.
Why store 50,000 SDK copies of 3 MB instead of asking a service per check, given 100 million checks a second?
Show answer
A remote check would be 100 million requests/s of 0.5 ms round trips and would put the flag service on every request path. Local evaluation costs memory (3 MB per process) and bounded staleness, a far better trade.
Sources and further study
Primary
- Holistic Configuration Management at Facebook (SOSP 2015): treats flag and config changes as reviewed, canaried code at very large scale.
- HTML Standard: Server-sent events: event stream format, reconnection and
Last-Event-IDresume. - OpenFeature specification: provider interface, evaluation context and resolution reasons.
Systems docs
- Unleash: architecture overview: SDKs cache all flag data in memory and evaluate locally.
- Unleash: activation strategies: rollout percentage via normalised MurmurHash and stickiness.
- Envoy xDS protocol: versioned config push with ACK and NACK.
- AWS Builders' Library: static stability: keep working when a dependency is impaired.
Books
- Site Reliability Engineering, Beyer et al. (Google), chapter 22 "Addressing Cascading Failures": retries, backoff with jitter and why synchronised clients amplify failure.
- Release It!, Michael Nygard, chapter 5 "Stability Patterns": timeouts, circuit breakers and fail-fast patterns for dependencies.
- Accelerate, Forsgren, Humble and Kim, chapter 4 "Technical Practices": evidence that decoupling deploy from release supports delivery performance.
Other curricula
- Martin Fowler: Feature Toggles: frames flags by category and lifespan, and warns about the cost of long-lived toggles.
Free resources
- OpenFeature: introduction: the vendor-neutral client API worth coding against.
- Canarying releases (SRE Workbook): separating feature launches from binary releases.
- Addressing cascading failures (SRE book): why jittered backoff and load shedding matter during recovery.
- PostHog: feature flags recurring outages: a candid post-mortem of a flag service under load.