System design interview
Design a feature flag service
Local rule evaluation, sticky percentage rollouts, kill switches and audit.
At a glance
- Foundation
- Consistency, Caching
- Flag API · Flag store · Publisher · Ruleset cache · Stream server · App SDK
Press Play to follow a request through the design, or step through it.
- Client and edge
- Service
- Data store
- Cache and queue
- An engineer raises the rollout of a flag from 10% to 25% in the admin UI.
- The API checks the change against the rules schema, then saves a new immutable version and an audit record.
- The API tells the publisher that the production environment has a new version.
- The publisher reads the current rules for that environment and compiles them into one compact snapshot.
- It writes the snapshot, labelled with its version number, to the ruleset cache.
- It also hands the stream server a small delta that holds only the changed flag.
- The stream server pushes the delta to every connected SDK, which applies it to the rules in memory.
- Admin UI
- Flag API: validates, saves
- Flag store: versions, audit
- Publisher: builds rulesets
- Ruleset cache: one file per env
- Stream server: pushes deltas
- App SDK: rules in memory
- Your app
On this page
In 2 minutes
- The numbers. Reads are huge and writes are tiny. So move the rules to the readers: keep them in each app's memory and evaluate there.
- Where flags are evaluated. Locally in an SDK, from rules in memory.
- How changes arrive. A stream, with polling as a fallback.
- Percentage rollouts. A hash of the flag and the user key.
- Clients in browsers and phones. Evaluate on the server and send only the results.
Read the 2-minute answerHide the 2-minute answer
The key choice is to move the rules to the readers. An app instance may check ten flags on every request, which at our assumed scale is 100 million checks a second, so a network call per check is out. Each app embeds an SDK that holds the whole ruleset, about 2 MB, in memory and evaluates locally in microseconds. Changes flow from an admin UI through a flag API into a store that keeps immutable, numbered versions and an audit log.
A publisher builds a snapshot for a CDN-backed cache and hands a small delta to a stream server, which pushes it to every SDK in about a second. Polling every 30 seconds is the safety net, and a version gap triggers a full fetch. If the service is down, the SDK keeps its last rules, so nothing fails. Percentage rollouts hash the flag key and the user key into one of 10,000 buckets, so a user stays in as the percentage grows.
A kill switch takes the same fast path with no batching. The trade-off is that rules live in every app, so SDKs must agree on the hash and validate every snapshot.
Step 1 of 6: Clarify the scope
Questions to ask first
Ask these before you draw anything. Guess why each answer changes the design, then open it to check.
Who asks for flags: backend servers, web browsers, mobile apps, or all three?
How rich are the targeting rules: plain on or off, lists of users, user attributes, percentages?
How fast must a change reach the apps?
What should an app do if the flag service is down?
Do we need approvals, history and an audit trail?
Requirements
Functional
- Create flags with on or off values, targeting rules and percentage rollouts.
- Evaluate a flag for a given user inside the app, with a safe default if anything is missing.
- Push every change, including an emergency kill switch, to all connected apps.
- Keep a version history and an audit trail of who changed what and when.
Non-functional
- A flag check adds under 1 ms at p99 and makes no network call.
- 99% of connected apps see a change within 5 seconds.
- Apps keep working with their last known rules while the service is down.
- A user who is in a rollout stays in it when the percentage grows.
Step 2 of 6: Size it
Show the mathsHide the maths
These numbers are assumptions for the exercise, not facts about a real system:
- 20,000 app instances run the SDK. At peak each handles 500 requests a second and checks about 10 flags per request.
- There are 2,000 flags in one environment, and compact rules average about 1 KB per flag.
- Teams make about 200 flag changes a day. Each touches one flag, so a change is a delta of about 1 KB.
- A local evaluation takes about 2 microseconds. A call to a remote service in the same region takes about 1 ms.
The arithmetic:
- Flag checks per second
- 20,000 instances × 500 requests/s × 10 flags
- 100 million/s in total, far too many for a remote call each
- Size of all rules
- 2,000 flags × 1 KB
- ≈ 2 MB, small enough to hold in every app's memory
- Cost per request, local
- 10 flags × 2 µs
- 20 µs, about 0.02 ms
- Cost per request, remote
- 10 flags × 1 ms, one call each
- ≈ 10 ms added to every request
- One change, full ruleset pushed
- 20,000 instances × 2 MB
- 40 GB for a one-line change
- One change, delta pushed
- 20,000 instances × 1 KB
- 20 MB, 2,000 times less
Step 3 of 6: Sketch the path of one request
Step through the diagram at the top of the page to watch a request move through these parts.
People change flags in the Admin UI, which calls the Flag API. The API validates the change and saves it to the Flag store, together with a new version number and an audit record. Nothing in the request path of any app depends on this part.
The Publisher is told about the change. It reads the current rules from the store, builds a versioned snapshot for each environment and writes it to the Ruleset cache, which is a file store behind a CDN. It also hands the change to the Stream server, which pushes a small delta to every connected app.
Each application embeds an App SDK. On start it downloads the snapshot and keeps it in memory. It opens a stream to hear about changes, and polls the cache as a fallback. When the Your app code asks whether a flag is on for a user, the SDK answers from memory with no network call.
Other paths through the system
Start up and check a flag
- A new app instance starts. Its SDK asks the ruleset cache for the latest snapshot.
- The cache returns the snapshot and its version number, and the SDK loads it into memory.
- The SDK opens a stream to the server and says which version it holds, so it only receives newer changes.
- The SDK reports that it is ready, and the app starts to take traffic.
- A request arrives. The app asks the SDK whether the new checkout is on for user 42, with a default of off.
- The SDK evaluates the rules in memory, with no network call, and answers in microseconds.
The service is unreachable
- The stream drops. The SDK tries to reconnect, waiting a random time so thousands of apps do not retry together.
- It falls back to polling the ruleset cache, and the poll fails too.
- The SDK keeps answering the app from the last ruleset it holds. Flags stay as they were, and no request fails.
Step 4 of 6: Go deep on the hard part
Local evaluation and delivery
How does an app check a flag in microseconds, and how do changes and outages reach it?The core idea: ship the rules to the app, not the question to the service. The SDK keeps the whole ruleset in memory.
The core idea: ship the rules to the app, not the question to the service. The SDK keeps the whole ruleset in memory. Evaluation is a pure function. It takes the flag's rules and a context, such as the user ID and country, and returns a value. There is no network call, so the check cannot be slow and cannot fail because of the service.
Delivery has two paths. On start, the SDK downloads the latest snapshot from the cache, which is a static file that a CDN serves cheaply. After that it learns about changes in one of two ways:
- A stream. The SDK holds a long-lived connection, such as server-sent events. The server pushes a small delta when a flag changes. Changes arrive in about a second. The cost is 20,000 open connections, which at an assumed 10,000 per node is three nodes with one spare.
- Polling. Every 30 seconds the SDK asks the cache for the latest version, sending the version it holds. If nothing changed, the answer is a tiny 'not modified'. It is simple and works through any proxy, but a change can take up to 30 seconds. At 20,000 instances, that is about 670 requests a second.
Use both. The stream gives speed, and the poll is the safety net if a push is lost. Each delta carries a version number. If the SDK sees a gap, say it holds version 411 and receives 413, it fetches the full snapshot rather than guess.
When the service is down, the SDK keeps its last ruleset. It can also write the ruleset to local disk, so an instance that restarts during an outage still starts with real rules. If it has nothing at all, it returns the default that the caller passed in. After a long outage, reconnects should wait a random time, so 20,000 apps do not hit the stream server in the same second.
Browsers and phones are different. Shipping the full rules would leak targeting details, such as staff lists. So the server evaluates for that one user and sends back only the resulting flag values, which the client caches.
Sticky percentage rollouts
How do you turn a flag on for 10% of users so that a user's experience stays the same as you raise it?Do not pick randomly on each request.
Do not pick randomly on each request. A user would see the new checkout on one page and the old one on the next. Instead, give every user a fixed position and compare it to the percentage.
The position is a bucket. Hash the flag key and the user key together, and take the result modulo 10,000. The same inputs always give the same bucket. A user is in the rollout when their bucket is below the percentage times 100. At 10% the cut-off is 1,000, and at 25% it is 2,500.
A worked example. User 42 hashes to bucket 1,834 for this flag. At 10% the cut-off is 1,000, so they are out. When the team raises the flag to 25%, the cut-off is 2,500 and they are in. Nobody who was in at 10% is removed, because the buckets below 1,000 are still below 2,500. Growing a rollout only adds people. Lowering it removes the people with the highest buckets first.
Details that matter:
- Include the flag key in the hash. If every flag hashed only the user, the same 10% of users would be first in line for every experiment.
- Use a stable key. A user ID works. A session ID or an IP address changes, so the person flips in and out. For signed-out visitors, store a random device ID in a cookie.
- Use the same hash in every SDK. A Java server and a JavaScript server must put user 42 in the same bucket. Pick one simple, non-cryptographic hash, write down the exact bytes that go into it, and publish test vectors that every SDK must pass.
- Allow a reseed. A salt field on the flag lets you re-draw the groups on purpose, for a new experiment.
Rules run in order: a user on the blocked list first, then specific users, then attribute rules such as country, then the percentage, then the default. For a flag with three variants, split the 10,000 buckets by weight, say 0 to 4,999, 5,000 to 7,999 and 8,000 to 9,999.
Kill switches, versions and the audit trail
A bad release is hurting users. How does a kill switch reach every app in seconds, and how do you trust the change history?A kill switch is an ordinary flag that wraps risky code and defaults to the safe path.
A kill switch is an ordinary flag that wraps risky code and defaults to the safe path. Its value is that turning it off is fast and needs no deploy. Speed is the requirement, so the path must have no slow step. Budget it: saving the change takes about 50 ms, the publisher about 100 ms, and the push to three stream nodes with about 6,700 connections each takes under a second. The SDK applies it in microseconds. That is a couple of seconds end to end, well inside the 5-second target.
Keep the kill-switch path free of batching. A delay that groups changes for a few seconds saves nothing here, so changes go out the moment they are saved. The polling fallback sets the worst case at 30 seconds if a push is lost.
Versions. Every save creates a new immutable version of the environment's ruleset, with a number that only goes up. The SDK remembers which version it holds. To roll back, republish the old contents as a new version, for example 415 with the contents of 412. Never reuse a number, or an SDK that holds 413 could not tell what changed.
Make rollouts observable. Each SDK reports the version it runs in a periodic heartbeat. The dashboard can then say '19,800 of 20,000 instances are on version 415' and name the ones that are not. Without it, 'it should have reached everyone' is a guess.
Audit. Write an append-only record for every change: who, when, which flag, the value before and after, and a reason or ticket. Never edit or delete these rows. Add permissions per environment, so more people can edit staging than production, and an optional approval step for production changes. Validate rules against a schema when they are saved. The SDK should also refuse a snapshot that fails to parse and keep the last good one.
Step 5 of 6: Name the trade-offs
- Locally in an SDK, from rules in memory
- A call to the flag service on every check
Checks take microseconds and survive an outage. The cost is that rules are copied to every app and every SDK language must give the same answer.
- A stream, with polling as a fallback
- Polling only
Changes land in about a second, and a lost push is repaired by the next poll. The cost is a fleet of long-lived connections to run.
- A hash of the flag and the user key
- A random draw on each request
A user keeps the same experience as the percentage grows. The cost is that you need a stable user key, and small audiences can land unevenly.
- Evaluate on the server and send only the results
- Send the full rules to the client
Targeting details stay private and the payload is small. The cost is a network call at start and a stale value until the client refreshes.
Step 6 of 6: Say what breaks next
Follow-up questions an interviewer asks once the design is on the board. Answer each one out loud, then open it to compare.