Feature Flag System Interview Questions | JiQuest

add

#

Feature Flag System

System design deep dive · HLD

Design a Feature Flag / Experimentation System (LaunchDarkly-style): full high-level design.

Requirements, back-of-envelope capacity estimation, a high-level design diagram, a deeper deployment architecture diagram, sequence diagrams for request-time evaluation and real-time config propagation, and an entity-relationship diagram for the data model - with the reasoning an interviewer expects behind every box and arrow.

16M/sFlag evaluations
<1msIn-process eval latency
50kConnected SDK instances
Requestuser_id=42 Client SDKevaluates in-process Local config cacheno network call Variant: onreturned sync Event batchshipped async no round trip

1. Clarify requirements before drawing any box

A strong system design answer starts by pinning down scope out loud. For a feature flag / experimentation platform, that means separating "evaluate a flag correctly and fast" from "propagate a change everywhere instantly" - two very different distributed-systems problems that live under one product.

Functional requirements

Evaluate a flagGiven a flag key and a user/request context, apply targeting rules and return a variant or boolean.
Gradual rolloutSupport percentage-based rollout (e.g. 10% of traffic) with consistent bucketing - the same user always lands in the same bucket.
Real-time propagationA toggle or new targeting rule reaches every evaluating client within seconds, not on the next deploy.
Evaluation loggingLog which variant was shown to which user, for downstream experiment analysis.

Non-functional requirements

Near-zero latencyEvaluation runs on nearly every request; it must be sub-millisecond and in-process, never a network round trip.
Fast propagationConfig changes, especially kill switches, must reach all clients within seconds.
Safe degradationIf the flag service is unreachable, clients keep serving their last-known-good cached config.
Stable bucketingBucket assignment can't reshuffle as rollout percentage or rule order changes - no flicker between variants.
Explicitly out of scope The statistical experiment-analysis / stats-engine that decides whether variant B beat variant A is out of scope - a separate analytics pipeline consumes the evaluation events this system emits. A full RBAC admin UI for managing who can edit which flags is also out of scope; assume a simpler auth check on the Admin/API.

2. Back-of-the-envelope capacity estimation

These numbers decide almost everything downstream: why evaluation cannot touch the network, how big a config payload each SDK instance holds in memory, and how aggressively evaluation events must be sampled before they hit Kafka.

MetricAssumptionResulting estimate
Active flags5,000 flags across the orgA full config snapshot is thousands of small rule objects - hundreds of KB, not GB.
Request volume2,000,000 requests/sec across the fleetBaseline load driving evaluation volume.
Flag evaluations/sec~8 evaluations per request average2M × 8 = 16M evaluations/sec, ~100% done in-process (compute cost, not network cost).
Connected SDK instances50,000 service instances embed the SDKEach needs the relevant flag config pushed on every change, not just at boot.
Config payload per client~300 KB per instance (flags relevant to that service)Full broadcast ≈ 50,000 × 300KB ≈ 15 GB; a delta push after the first sync is orders of magnitude smaller.
Evaluation-event volumeSample ~1% of evaluations, only for flags in an active experiment16M/sec × 1% = 160,000 events/sec × ~200 bytes ≈ 32 MB/sec sustained Kafka throughput, ≈ 2.7 TB/day.
Why this matters 16 million evaluations per second done anywhere except fully in-process, on the calling thread, with zero network calls would be an outage-generating machine on its own. Every architecture decision below - local SDK caching, streaming config push instead of polling, sampled and batched event logging - exists to keep that number a pure CPU cost instead of a network or availability liability.

3. High-level design (HLD)

The HLD names the major components and the one-directional data flows between them: config authored once, config evaluated everywhere, evaluations logged asynchronously - without yet committing to specific regions, replica counts, or transport protocols.

Admin / APIflag & rule CRUD Flag config storeflags + targeting_rules Config distribution svcSSE stream, versioned Client SDKin-process eval,local config cache Client SDKin-process eval,local config cache Client SDK... × 50,000instances Host servicecalls SDK.evaluate() Evaluation event pipelineasync batch, sampled, → Kafka Analytics / experiment storeout of scope in detail
Control plane / callerConfig & evaluation pathDurable source of truthAsync / sampled

What each box owns

Admin / API

Lets an operator create a flag, edit its default variant, and author ordered targeting rules ("enabled for segment X, or for 10% of traffic"). Writes go to the flag config store and nowhere else - the Admin/API never sits on the evaluation hot path.

Flag config store

The durable source of truth: flags and targeting_rules tables. Every write bumps a per-flag (or global) version number, which is the mechanism the distribution service uses to know something changed and to let a reconnecting client ask "what changed since version N?" instead of re-downloading everything.

Config distribution service

Holds long-lived connections (Server-Sent Events, or a websocket where bidirectional acks are wanted) to every connected SDK and pushes a config snapshot or delta the moment the version number changes. It is the piece that turns "an admin clicked save" into "every service reflects it within seconds," and it is why this system does not rely on polling or the next deploy.

Client SDK (embedded in each service)

Runs inside the host process. Holds the full relevant config in memory, and evaluates every flag with a deterministic hash-bucketing function - no network call per evaluation, ever. This is the component that makes 16 million evaluations/sec a non-issue: it's a hash and a comparison, not an RPC.

Evaluation event pipeline

The SDK batches "flag X evaluated as variant Y for user Z" events in memory and ships them asynchronously and off the hot path, sampled down (only for flags actually part of an active experiment) before landing in Kafka - so logging volume never scales 1:1 with evaluation volume.

Analytics / experiment store

Consumes the Kafka stream for offline analysis - which variant users saw, joined against downstream conversion events. The statistics engine that turns this into a significance result is a separate system and is explicitly out of scope here.

4. Detailed architecture diagram

The architecture diagram takes every HLD box and answers "how is this actually deployed and kept decoupled from the hot path?" - replica counts, the streaming transport, and exactly where the async event pipeline splits off so it can never add latency to an evaluation.

Control plane Admin UI / API Config storeprimary + replicas, versioned Distribution svc ×8SSE gateway, ETag/version aware Connection registrytracks 50,000 live SDK sessions Application fleet (each instance embeds the SDK) Service A pods ×300 SDK: in-mem config cache Service B pods ×600 SDK: in-mem config cache ... 400+ services SDK: in-mem config cache Evaluation:hash(user_id+flag_key)mod 100 → bucketzero network calls Async evaluation-event pipeline (decoupled) SDK-side batcher Kafka topic (sampled) fire-and-forget - never blocks an evaluation call Analytics cluster Stream consumers Experiment store stats-engine consumes this - out of scope here
DecisionChoiceReasoning
Where evaluation runsFully in-process in the SDK, not a server round-tripAt 16M evaluations/sec, even a 1ms network round trip would add tens of thousands of CPU-seconds of waiting per second fleet-wide; local evaluation makes the cost a nanosecond-scale hash instead.
Config propagation transportStreaming (SSE) over polling; websocket only where acks matterPolling every few seconds either wastes requests when nothing changed or is too slow for a kill switch; a push-based stream delivers changes the moment the version bumps, at a fraction of the connection overhead of a websocket for a mostly server-to-client flow.
Bucketing approachhash(user_id + flag_key) mod 100Concatenating the flag key makes bucket assignment independent per flag - a user in the top 10% for flag A is not correlated with their bucket for flag B - while staying deterministic and stateless (no lookup table to maintain).
Evaluation-event samplingSample + log only for flags in an active experimentLogging every one of 16M evaluations/sec would make the event pipeline the bottleneck the flag system itself was built to avoid; sampling and filtering to "flags that matter for analysis" cuts volume by orders of magnitude with no loss of experiment validity.

5. Sequence diagrams for the two critical flows

A sequence diagram is where an interviewer checks whether you actually understand what's synchronous versus fire-and-forget, and specifically whether evaluation ever waits on the network - it must not, in either normal operation or during a flag-change push.

5.1 Flag evaluation at request time

Request Service Client SDK Local config Batcher 1. GET /checkout (user_id=42) 2. evaluate("new-checkout-flow", ctx) 3. read cached rules (in-memory) 4. rules[] 5. match segment? else hash(uid+key) mod 100 6. return variant "on" 7. enqueue eval event (fire-and-forget) 8. response uses variant "on"

Steps 3-6 never leave the process - "read cached rules" is a field access on an object already resident in memory, not an RPC, which is why the arrow exists on the diagram purely to show call order rather than network hops. Step 7 is deliberately fire-and-forget: the SDK appends to an in-memory batch and returns immediately, so logging can never add latency to the response in step 8, and a full or slow batcher degrades to dropping events rather than blocking evaluation.

5.2 Real-time flag-change propagation (kill switch)

Admin Config store Distribution svc SDK (live) SDK (dropped) 1. toggle "checkout-v2" off 2. persist + version++ (v41→v42) 3. notify: new version v42 4. push delta over SSE stream 5. atomically swap in-memory snapshot 6. attempted push - connection dropped 7. keeps evaluating on last-known-good v41 (kill-switch flag fails closed) 8. next evaluate() on live SDK returns "off" 9. reconnect, sync from v39-last-seen → v42 delta

Step 4 is the whole point of the streaming transport: the admin's change reaches every live SDK within seconds, and step 5's atomic snapshot swap means no evaluation ever sees a half-updated config - it's either fully on v41 or fully on v42, never a mix of old and new rules for the same request. Steps 6-7 show the safety property that matters most for a kill switch: an SDK that loses its connection does not error or block, it keeps evaluating against its last cached version, and by design a kill-switch flag is configured to fail closed (off) on any doubt, while a cosmetic flag can be configured to fail open instead.

6. Entity-relationship (ER) diagram and schema

The data model has to answer three questions: how are ordered targeting rules attached to a flag, what makes bucketing deterministic and reproducible, and why the highest-volume table here doesn't really belong in a relational database at all.

flags PK id BIGINT UQ key VARCHAR description TEXT default_variant VARCHAR is_enabled BOOLEAN version BIGINT updated_at TIMESTAMP targeting_rules PK id BIGINT FK flag_id BIGINT rule_order INT segment_condition JSONB rollout_percentage SMALLINT variant VARCHAR created_at TIMESTAMP evaluated top-down per flag by rule_order evaluation_events PK id BIGINT FK flag_id BIGINT user_id VARCHAR variant_shown VARCHAR evaluated_at, wide-column store 1N 1N one flag has many ordered targeting_rules; one flag has many (append-only) evaluation_events

Key modeling decisions

rule_order makes rules a list, not a setRules are evaluated top-down per flag; the first matching rule wins, so rule_order is part of the primary access pattern, not just a display hint.
version lives on flags, not a separate tableA single monotonically increasing counter per flag is exactly what the distribution service needs to answer "has anything changed since the client's last-seen version?" cheaply.
segment_condition is JSONB, not more foreign keysA constrained rule DSL (attribute, operator, value) serializes naturally as JSON and avoids an explosion of join tables for every possible targeting attribute.
evaluation_events doesn't belong in the OLTP databaseAt 160,000 sampled events/sec it's append-only, time-partitioned, and read via aggregate scans - a wide-column or columnar analytics store fits its access pattern far better than a row store tuned for point lookups.
Storage choiceUse whenWatch out for
Relational (Postgres/MySQL) for flags + targeting_rulesLow write volume (admin edits), need for strong consistency on version bumps and ordered-rule integrity.Not built for the evaluation_events volume - don't be tempted to reuse the same instance for both.
Wide-column / columnar (Cassandra, ClickHouse, or a data-lake table) for evaluation_eventsExtremely high append-only write volume, queried by time range and flag_id for offline analysis.Point lookups by a single user_id across all time are expensive; partition by flag_id + time bucket to match the actual query pattern.

7. Deep dives interviewers actually probe

How does consistent percentage bucketing avoid reshuffling users when a rollout grows from 10% to 50%?

Hash the user_id concatenated with the flag key into a stable number in [0, 99] - concatenating the flag key matters because it makes a user's bucket for flag A statistically independent of their bucket for flag B, instead of every rollout secretly correlating with the same underlying "lucky number." Rollout percentage is then just "which buckets count as in" - 0-9 for 10%, and growing that to 0-49 for 50% only ever adds buckets, never removes or renumbers the ones already in, so anyone already enabled stays enabled.

// Deterministic 0-99 bucket, independent per flag
function bucketFor(userId, flagKey) {
  const h = murmurhash32(`${userId}:${flagKey}`);
  return h % 100; // stable for this (user, flag) pair forever
}

function isEnabled(userId, flagKey, rolloutPercentage) {
  return bucketFor(userId, flagKey) < rolloutPercentage; // monotonic expansion
}

Why must evaluation happen fully in-process in the SDK rather than as a server round-trip?

At roughly 16 million evaluations per second fleet-wide, even a modest 1ms network round trip would add on the order of 16,000 CPU-seconds of pure waiting every second across the fleet, plus make every flag check a new point of failure and a load-balancer hop. The SDK sidesteps this entirely by caching the full config relevant to its service locally and treating evaluation as a hash lookup and a comparison - correctness is maintained by keeping that local cache continuously synced via the streaming distribution channel, not by asking a server on every call.

What happens when a client's connection to the config-distribution service drops?

The SDK keeps evaluating against its last-known-good cached config rather than blocking the calling request or throwing - a disconnected SDK is still a correctly-functioning SDK, just a slightly stale one. Whether "stale" should mean "fail open" or "fail closed" is deliberately a per-flag decision rather than a global one: a kill-switch flag (turning off a broken feature) should default to off/closed if evaluation ever can't confirm the latest state, while a low-risk UI-tweak flag can safely fail open and keep showing its last-seen variant, because the cost of staleness is asymmetric across flag types.

How do you keep evaluation-event logging from becoming a bottleneck or a huge cost at billions of evaluations a day?

Three levers, applied together: the SDK batches events in memory and ships them on an interval or size threshold rather than one network call per evaluation; only a sampled fraction of evaluations are logged (the capacity table above uses ~1%, tunable per experiment's required statistical power); and, more importantly, events are only generated at all for flags actually attached to an active experiment - a boolean ops kill-switch flag with no experiment behind it produces zero logging overhead, because there's nothing to analyze.

How would you support targeting on complex custom attributes without turning the rule engine into an unbounded query language?

Constrain segment_condition to a fixed, small set of operators over known attribute types - equals/in for strings and enums, greater-than/less-than/between for numbers and dates, contains for sets - rather than accepting an arbitrary expression or embedded scripting language. "Enabled for enterprise-tier accounts created after date X in region EU" becomes a small AND of three typed comparisons the SDK can evaluate in microseconds and the config UI can render as a form, not a query planner - the same discipline that keeps evaluation fast also keeps the targeting rules auditable and impossible to accidentally turn into a denial-of-service vector.

8. Summary: what a strong answer covers

Separated evaluation from config propagationJustified every number with a calculationKept evaluation fully in-process Named the bucketing function and whyMade event logging async and sampledMade fail-open vs fail-closed a per-flag choice
Interview tip When asked to design a feature flag system, the strongest signal is treating in-process evaluation as sacred and config propagation as the thing that's allowed to be "merely" seconds-fast rather than instant - most candidates try to make evaluation itself "real-time" over the network and accidentally reintroduce the exact latency and availability risk the SDK pattern exists to eliminate.
No comments
Leave a Comment