Skip to the solution
HardSystem Design10 min read

Advanced feature flags — targeting, gradual rollout, and edge evaluation?

609 views

The problem

Question presented to candidate: "Say our flag system already delivers rules to every SDK. How does a flag actually decide what one user gets? Walk me through targeting rules, how a 10% rollout stays stable as we ramp to 100%, and what changes if we evaluate flags at the CDN edge instead of in our servers."

What a strong answer should cover:

  • A fixed evaluation order: off switch, prerequisites, individual targets, rules top to bottom (first match wins), then the fallthrough rollout, with the matched reason returned for debugging
  • Deterministic bucketing: hash of flag key, a per-flag salt and the bucketing key into a fixed range (LaunchDarkly documents 100,000 buckets), so the same user always gets the same answer without storing assignments
  • Why ramps are monotonic (raising 10% to 25% only adds users) and why a different salt per flag keeps rollouts of different flags independent; bucketing by organisation or device instead of user when consistency matters at that level
  • Gradual rollout as a controlled process: stages, guardrail metrics, automatic rollback, and stable assignment for experiments
  • Edge evaluation: lower latency and privacy (browsers get evaluated values, not rules), but eventually consistent config per location, so users can flip between versions unless you pin a version; and flag-dependent responses must not be cached as one shared page

Clarifying questions expected:

  • Who is the unit of rollout: a user, an organisation, a device, a request?
  • Is this a release (just ramp safely) or an experiment (needs stable assignment and statistics)?
  • Which edge platform, and how fast does its config storage propagate?

Code / implementation expected: Optional — the examples implement rule evaluation and hash bucketing in four languages, and simulate edge propagation with version pinning.

  • feature-flags
  • edge
  • experimentation

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

10 min read · 10 sections

Read the code

Targeting rules and sticky percentage rollouts: rule order, SHA-1 buckets, monotonic ramps, per-flag independence, bucketing by org
// Targeting rules + sticky percentage rollouts, evaluated locally like a server-side SDK.
const crypto = require("crypto");

// bucket in [0, 99999]: first 32 bits of SHA-1("flag.salt.key") scaled to 100,000 buckets
function bucket(flagKey, salt, key) {
  const hex = crypto.createHash("sha1").update(flagKey + "." + salt + "." + key).digest("hex");
  const v = parseInt(hex.slice(0, 8), 16);
  return Math.floor((v * 100000) / 4294967296);
}

const N = 100000;
const users = [];
for (let i = 0; i < N; i++) {
  users.push({
    key: "user-" + i,
    org: "org-" + Math.floor(i / 25),
    country: i % 20 === 0 ? "NZ" : "US",
    plan: i % 7 === 0 ? "enterprise" : "free",
  });
}
const betaTesters = new Set();
for (let i = 7; i < N; i += 1000) betaTesters.add("user-" + i);

const flag = {
  key: "new-checkout", salt: "a1b2", on: true, offValue: false,
  targets: new Set(["user-42", "user-4242"]),
  rules: [
    { name: "country in [NZ]", match: (u) => u.country === "NZ", serve: true },
    { name: "segment beta-testers", match: (u) => betaTesters.has(u.key), serve: true },
    { name: "plan is enterprise", match: (u) => u.plan === "enterprise", serve: false },
  ],
  rolloutPct: 10, bucketBy: "key",
};

function evaluate(f, u) {
  if (!f.on) return { value: f.offValue, reason: "OFF" };
  if (f.targets.has(u.key)) return { value: true, reason: "TARGET" };
  for (let r = 0; r < f.rules.length; r++) {
    if (f.rules[r].match(u)) return { value: f.rules[r].serve, reason: "RULE " + r + " (" + f.rules[r].name + ")" };
  }
  const b = bucket(f.key, f.salt, u[f.bucketBy]);
  return { value: b < f.rolloutPct * 1000, reason: "FALLTHROUGH bucket " + b };
}

console.log("== A. who gets what (" + N + " users, 10% fallthrough rollout) ==");
const counts = new Map();
for (const u of users) {
  const r = evaluate(flag, u);
  const label = (r.reason.startsWith("FALLTHROUGH") ? "FALLTHROUGH" : r.reason) + " -> " + r.value;
  counts.set(label, (counts.get(label) || 0) + 1);
}
for (const k of [...counts.keys()].sort()) console.log("  " + k.padEnd(48) + String(counts.get(k)).padStart(6));

console.log("== B. sample evaluations ==");
for (const k of ["user-42", "user-40", "user-1007", "user-7007", "user-14", "user-1", "user-2"]) {
  const u = users[Number(k.slice(5))];
  const r = evaluate(flag, u);
  console.log("  " + k.padEnd(10) + " " + String(r.value).padEnd(6) + r.reason);
}

console.log("== C. ramping the rollout: sticky hash vs re-rolled hash ==");
const steps = [1, 5, 10, 25, 50, 100];
let prevSticky = null, prevNaive = null;
for (const p of steps) {
  const sticky = users.map((u) => bucket("new-checkout", "a1b2", u.key) < p * 1000);
  const naive = users.map((u) => bucket("new-checkout", "pct" + p, u.key) < p * 1000);
  const on = sticky.filter(Boolean).length;
  const lostS = prevSticky ? sticky.filter((v, i) => prevSticky[i] && !v).length : 0;
  const lostN = prevNaive ? naive.filter((v, i) => prevNaive[i] && !v).length : 0;
  console.log("  " + String(p).padStart(3) + "%  on=" + String(on).padStart(6) +
    "  sticky lost=" + String(lostS).padStart(5) + "  re-rolled lost=" + String(lostN).padStart(5));
  prevSticky = sticky; prevNaive = naive;
}

console.log("== D. two 10% flags: same hash input vs different flag keys ==");
const inA = users.map((u) => bucket("new-checkout", "a1b2", u.key) < 10000);
const inCopy = users.map((u) => bucket("new-checkout", "a1b2", u.key) < 10000);
const inB = users.map((u) => bucket("dark-mode", "c3d4", u.key) < 10000);
const both = (x, y) => x.filter((v, i) => v && y[i]).length;
console.log("  same key+salt: both on=" + both(inA, inCopy) + "  different key+salt: both on=" + both(inA, inB));

console.log("== E. 50% rollout, 25 users per org: bucket by user vs by org ==");
for (const by of ["key", "org"]) {
  const perOrg = new Map();
  for (const u of users) {
    const v = bucket("new-checkout", "a1b2", u[by]) < 50000;
    const s = perOrg.get(u.org) || { on: 0, off: 0 };
    v ? s.on++ : s.off++;
    perOrg.set(u.org, s);
  }
  let mixed = 0;
  for (const s of perOrg.values()) if (s.on > 0 && s.off > 0) mixed++;
  console.log("  bucketBy=" + by.padEnd(4) + " orgs=" + perOrg.size + "  orgs with mixed values=" + mixed);
}
Edge evaluation: eventually consistent config per PoP, flip-flops and version pinning, rules vs evaluated values for browsers
// Edge flag evaluation: eventually consistent config at each PoP, a user hopping between PoPs,
// version pinning to stop flip-flops, and what a browser should (not) be sent.

// Config versions. v2 is the kill switch: new-checkout off for everyone.
const configs = { 1: { "new-checkout": true }, 2: { "new-checkout": false } };
// Assumed propagation delay of v2 to each PoP after publish at t=0 (KV-style store: up to about 60 s).
const pops = [
  { name: "SYD", delay: 5 },
  { name: "SIN", delay: 22 },
  { name: "LAX", delay: 58 },
];
const versionAt = (pop, t) => (t >= pop.delay ? 2 : 1);

// The user moves between PoPs (mobile network, anycast route change): one request every 4 s.
function simulate(pinning) {
  let seen = 0, flips = 0, last = null, originCalls = 0;
  const trace = [];
  for (let t = -8, i = 0; t <= 64; t += 4, i++) {
    const pop = pops[i % pops.length];
    let v = versionAt(pop, t), where = pop.name;
    if (pinning && v < seen) { v = seen; where = "origin"; originCalls++; } // edge is older than the client: ask origin
    const value = configs[v]["new-checkout"];
    if (last !== null && value !== last) flips++;
    last = value; seen = Math.max(seen, v);
    trace.push(`t=${t}s ${where}:v${v}:${value ? "on" : "off"}`);
  }
  return { trace, flips, originCalls };
}

for (const pinning of [false, true]) {
  const r = simulate(pinning);
  console.log(pinning ? "== with version pinning (a cookie carries the highest config version seen) ==" : "== without version pinning ==");
  for (let i = 0; i < r.trace.length; i += 4) console.log("  " + r.trace.slice(i, i + 4).join("  "));
  console.log(`  value changes seen by the user: ${r.flips}   requests sent to origin: ${r.originCalls}`);
}

// What to ship to a browser: the rules, or only the evaluated values for this user?
const flagsCount = 200;
const rulesConfig = {};
const evaluated = {};
for (let f = 0; f < flagsCount; f++) {
  const targets = [];
  for (let u = 0; u < 50; u++) targets.push("user-" + (f * 1000 + u));
  rulesConfig["flag-" + f] = {
    on: true, salt: "s" + f, targets,
    rules: [{ attr: "email", op: "endsWith", values: ["@bigcustomer.com"], serve: 0 }],
    rollout: [{ variation: 0, weight: 10000 }, { variation: 1, weight: 90000 }],
  };
  evaluated["flag-" + f] = f % 3 === 0;
}
const rulesBytes = JSON.stringify(rulesConfig).length;
const valuesBytes = JSON.stringify(evaluated).length;
console.log("== client payload for 200 flags ==");
console.log(`  full rules: ${rulesBytes} bytes (exposes 10000 targeted user keys and customer domains)`);
console.log(`  evaluated values: ${valuesBytes} bytes (${(rulesBytes / valuesBytes).toFixed(1)}x smaller, nothing to leak)`);

// Latency for a page that needs its flags before rendering (assumed round-trip times).
const userToPop = 20, popToOrigin = 120, evalMs = 0.05;
console.log("== time to know the flags for one page ==");
console.log(`  evaluate at origin: ${userToPop + popToOrigin + evalMs} ms   evaluate at edge: ${userToPop + evalMs} ms`);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 78 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

How would you design a rate limiter?

Continue