Module 2: Feature Flags

Gradual exposure: the same flag, a percentage of users

Description

Until now, recommendations' flag was binary: enabled: true showed it to everyone, enabled: false showed it to nobody. Lesson 3's registry already had a rolloutPercent: 10 field waiting — but nothing, yet, turned it into a real decision. This lesson builds that piece: isEnabled(userId, flag), the central function of this whole module, which decides, buyer by buyer, whether they see recommendations, using a percentage instead of an all-or-nothing switch. It's the lesson you're going to run the most times through the rest of the guide.

Connection to the module. This is the lesson where the flag stops being a simple switch and becomes the tool that makes controlling module 1's blast radius possible in real code: blastRadius() showed how many people it's worth exposing first (125 of 250,000, with a 1% canary); isEnabled() is what actually makes only that fraction, and no more, see the feature. Lesson 5 adds the emergency shutdown to this same function.

An analogy: the guest list, not the door's all-or-nothing

A private event with limited capacity doesn't work with a single guard deciding "everyone gets in today" or "nobody gets in today" — it works with a guest list: a fixed criterion (is your name on tonight's list?) that any guard, at any door, applies exactly the same way. If your name is on the list, you always get in — it doesn't depend on which guard checked you or how tired they are that night. If the organizer decides to expand the list for the next date, the list grows, but it's still the same kind of decision: check the name, don't flip a coin at the door.

isEnabled(userId, flag) is that list, calculated mathematically instead of written by hand. Instead of storing 25,000 names in a file, it calculates, for each userId, a fixed number between 0 and 99 — their position on the "list" — and compares it against rolloutPercent. The result is exactly what you need from a real guest list: the same buyer always gets the same answer, no matter how many times they visit the page or which Mercado server handles their request.

Worked example: isEnabled() on ten Mercado buyers

Let's build the complete function: a deterministic hash of the userId (never Math.random() — that would break exactly the property we need) modulo 100, compared against rolloutPercent:

// isEnabled: decides whether a user sees a feature, using a deterministic
// hash of the userId (never Math.random -- that breaks the stability, which
// is this lesson's central point) modulo 100, compared against rolloutPercent (0-100).
function hashUserId(userId) {
  let hash = 0;
  for (let i = 0; i < userId.length; i++) {
    hash = (hash * 31 + userId.charCodeAt(i)) % 100;
  }
  return hash;
}

function isEnabled(userId, flag) {
  if (!flag.enabled) return false; // kill switch: enabled=false turns EVERYONE off, regardless of rolloutPercent (lesson 5)
  const bucket = hashUserId(userId + flag.name); // salted with the flag name: same user, different bucket per flag
  return bucket < flag.rolloutPercent;
}

const recommendationsFlag = { name: 'recommendations', enabled: true, rolloutPercent: 10 };

const buyers = ['buyer-ana', 'buyer-bruno', 'buyer-carla', 'buyer-diego', 'buyer-elena',
  'buyer-fabio', 'buyer-gina', 'buyer-hugo', 'buyer-irene', 'buyer-julio'];

console.log('=== isEnabled on 10 named buyers (rolloutPercent=10) ===\n');
buyers.forEach((id) => {
  console.log(id.padEnd(14) + '-> ' + isEnabled(id, recommendationsFlag));
});

What to expect. Running the file with Node, the output is exactly this:

=== isEnabled on 10 named buyers (rolloutPercent=10) ===

buyer-ana     -> false
buyer-bruno   -> false
buyer-carla   -> false
buyer-diego   -> false
buyer-elena   -> false
buyer-fabio   -> false
buyer-gina    -> false
buyer-hugo    -> false
buyer-irene   -> true
buyer-julio   -> false

Out of ten buyers, exactly one — buyer-irene — sees the feature. With a rolloutPercent of 10, you'd expect, in such a small sample, something close to 1 in 10 — and that's exactly what came out, though with only ten people any result between 0 and 2 would be just as statistically reasonable. To confirm the percentage with more confidence, you need a bigger sample.

Verifying the two properties that matter: the percentage, and the stability

Let's keep going in the same file, adding two more checks on the same function, without changing a single line of it:

console.log('\n=== Stability: same call, 3 times, for buyer-ana ===');
console.log([isEnabled('buyer-ana', recommendationsFlag), isEnabled('buyer-ana', recommendationsFlag), isEnabled('buyer-ana', recommendationsFlag)]);

// bulk check over 1000 synthetic users
let count = 0;
const total = 1000;
for (let i = 0; i < total; i++) {
  const id = 'user-' + String(i).padStart(4, '0');
  if (isEnabled(id, recommendationsFlag)) count++;
}
console.log('\n=== Statistical check over ' + total + ' synthetic users (rolloutPercent=10) ===');
console.log(count + ' of ' + total + ' see the feature = ' + (count / total * 100).toFixed(1) + '%');

What to expect. Running the file with Node, the output is exactly this:

=== Stability: same call, 3 times, for buyer-ana ===
[ false, false, false ]

=== Statistical check over 1000 synthetic users (rolloutPercent=10) ===
98 of 1000 see the feature = 9.8%

The two checks prove different things, and both matter. The first — calling isEnabled('buyer-ana', recommendationsFlag) three times in a row and getting false all three — confirms stability: there's no random component in the function at all; the same userId with the same flag produces, always, the same answer, no matter how many times it's called or when. The second — running the function over 1,000 synthetic users and counting how many land on true — confirms the percentage: 9.8%, very close to the 10% set in rolloutPercent. With a sample of 1,000 instead of 10, the result gets much closer to the theoretical percentage, exactly as you'd expect from any reasonably uniform distribution.

Why the hash, and why never Math.random()

The piece that makes stability possible is hashUserId(): a function that takes a string (userId + flag.name) and always produces the same number between 0 and 99, with no random component at all — it multiplies an accumulator by 31, adds each character's code to it, and applies modulo 100 at every step. You don't need to understand the exact arithmetic to trust the property that matters: the same text input always produces the same numeric output. That's, literally, the opposite of what Math.random() would do, generating a different number on every call, with no memory of previous calls.

Also notice the detail of userId + flag.name inside the hash, instead of using just userId. That "salting" with the flag's name exists so the same buyer doesn't always land in the same bucket for every Mercado flag — buyer-irene can be in the 10% that sees recommendations and, at the same time, outside the 50% that sees checkoutVariantB, because each flag calculates its own hash with its own name included. Without that detail, a buyer who fell into one flag's 10% would automatically fall into absolutely every Mercado flag's 10% — a correlation no team would want, and one that would break the validity of any experiment running in parallel.

One last property, just so you notice it today without diving deeper yet: if rolloutPercent climbs from 10 to 50, the buckets don't get recalculated — buyer-irene (bucket below 10) stays in, and new buyers with a bucket between 10 and 49 get added, without anyone who already saw the feature stopping seeing it. That property is what makes it possible, in principle, to raise a percentage without "resetting" anyone — but designing the full ramp, with its stages and its criteria for climbing, is exactly module 3's job, not this lesson's.

Common mistakes

Using Math.random() to decide who sees the feature. What happens: someone writes Math.random() < flag.rolloutPercent / 100 instead of a hash of the userId, and the function "works" in the sense that, in aggregate, close to the right percentage of requests get true — but every request rolls a new die, with no memory of previous ones. Why it happens: Math.random() is the shortest way to write "give me true X% of the time," and for a single isolated call it gives the expected result. How to spot it: ask the function to decide twice for the same userId, at different moments — if the two answers can differ, the bug is already confirmed. How to fix it: as in this lesson's example, the decision has to depend only on data that doesn't change between calls (userId, flag.name) — never on a memoryless source of random numbers.

Non-deterministic assignment: the same buyer sees the feature one day and not the next. What happens: it's the direct consequence of the previous mistake, seen from the user's experience — buyer-irene sees the recommendations carousel on their morning visit, and in the afternoon, in the same session, it's gone — with nobody having changed the flag. Why it happens: any source of randomness in the decision (Math.random(), the time of day, which server handled the request) produces this flicker, even if the aggregate percentage looks correct on a dashboard. How to spot it: support complaints like "I saw something yesterday and today it's gone" are the clearest signal — and it also breaks any attempt to measure the feature's effect, because a user who jumps between control and variant invalidates the experiment running it. How to fix it: this lesson's stability check — calling isEnabled() several times for the same userId and confirming the answer doesn't change — should be a mandatory automated test before trusting any percentage-based rollout logic.

A global boolean flag when the situation called for a per-user percentage. What happens: the team wants to expose recommendations "just a little" to test carefully, but since the original flag from lesson 2 only had enabled: true/false, the only way to "go slow" ends up being turning the flag on for an hour and off again, instead of stably exposing it to a real fraction of users. Why it happens: a boolean flag is simpler to reason about, and if nobody has built the rolloutPercent logic yet, "flip it on and off quickly" feels like the only available lever. How to spot it: if a team's "go slow" strategy depends on flipping the flag on and off instead of setting a stable percentage, it's a sign this lesson is missing. How to fix it: isEnabled(userId, flag) with rolloutPercent is exactly the right tool for this case — a fixed, stable percentage, not a flickering switch.

Exercises

Exercise 1 — Calculate the bucket by hand. Without running code, use hashUserId()'s logic to reason: if bucket('buyer-x' + 'recommendations') came out to, say, 7, and rolloutPercent is 10, does isEnabled() return true or false? What if rolloutPercent were 5?

See solution

With bucket = 7 and rolloutPercent = 10: 7 < 10 is true — the buyer sees the feature. With rolloutPercent = 5: 7 < 5 is false — the same buyer, with the same calculated bucket, no longer sees it. This exercise illustrates that a user's bucket doesn't change when rolloutPercent changes — it stays 7, always — what changes is the threshold it's compared against. It's the same monotonicity property mentioned at the end of the deep-dive section: raising the percentage can only add users who didn't qualify before, never remove ones who already qualified.

Exercise 2 — Prediction with two flags. buyer-irene has a bucket below 10 for recommendations (that's why isEnabled('buyer-irene', recommendationsFlag) returns true with rolloutPercent: 10). If Mercado activates a new flag, checkoutVariantB, with rolloutPercent: 50, is it safe to assume buyer-irene will also see that second flag? Why or why not, based on hashUserId()'s design?

See solution

It's not safe to assume — in fact, there's no guaranteed relationship between the two results. hashUserId() calculates the bucket with userId + flag.name, so the bucket for 'buyer-irene' + 'recommendations' and the one for 'buyer-irene' + 'checkoutVariantB' are, in practice, two independent calculations that produce different, uncorrelated numbers. That's precisely the purpose of the salting with the flag name explained in the lesson: preventing the same buyer from systematically landing inside or outside every Mercado flag at once.

Exercise 3 — Design the stability test. Write, in a couple of lines of pseudocode or JavaScript, a checkStability(userId, flag, times) function that calls isEnabled(userId, flag) the number of times indicated by times and returns true only if all the answers were identical.

See solution
function checkStability(userId, flag, times) {
  const results = [];
  for (let i = 0; i < times; i++) results.push(isEnabled(userId, flag));
  return results.every((r) => r === results[0]);
}

console.log(checkStability('buyer-irene', recommendationsFlag, 10)); // true

This function is, in essence, an automated version of the stability check you already saw in this lesson's example — calling several times and comparing — generalized for any number of repetitions. In a real system, a test like this would run as part of any feature flag implementation's automated test suite, precisely to catch the "non-deterministic assignment" bug before it reaches production.

Summary and next step

In this lesson you built isEnabled(userId, flag), this module's central function: a deterministic hash of the userId (never Math.random()), modulo 100, compared against rolloutPercent. You confirmed two properties on Mercado's case — at a rolloutPercent of 10, 9.8% of a sample of 1,000 synthetic users see recommendations, and buyer-ana, checked three times, always gets the same answer. Those two properties, correct percentage and guaranteed stability, are exactly what a gradual rollout needs from the mechanism holding it up.

Before moving on you should be able to: explain why a deterministic hash achieves stability and Math.random() doesn't; run isEnabled() mentally given a bucket and a rolloutPercent; and explain why each flag needs its own "salt" in the hash, instead of sharing a user's bucket across every flag.

Lesson 5 adds to this same function the piece missing for a real emergency: the kill switch, which has to override rolloutPercent regardless of its value, turning everyone off instantly.

Resources

  • LaunchDarkly, "What Is Progressive Delivery All About?" — launchdarkly.com/blog/what-is-progressive-delivery-all-about. Describes percentage-based rollout as the central technique of progressive delivery — exposing a feature to a specific fraction of users before deciding the rest — this lesson's whole topic.
  • Google SRE Workbook, Chapter 16, "Canarying Releases" — sre.google/workbook/canarying-releases. Documents feature flag/experiment frameworks as the mechanism that separates a feature's rollout from a binary release, with fractional exposure like isEnabled()'s.