Laptop to Production

Feature flags

Shipping code without switching it on.

Time
35 minutes
Level
Beginner
Read first
Ways to release

Part 1: The problem

QuickCart, the new checkout

The team is rebuilding checkout. It takes six weeks, so Ana works on a separate branch. By the time it’s finished, the main branch has moved on so far that merging takes two days.

The new checkout ships on a Thursday, to everyone at once. By Thursday evening, customers paying with one kind of card can’t finish an order. The fix needs a new build of the server, and a new version of the mobile app. The app store review takes a day and a half.

For a day and a half, some customers can’t pay, and there’s nothing the team can do but wait.

QuickCart needs two things: a way to ship the new checkout without switching it on, and a way to switch it off in seconds. Feature flags give both. The case above shows what happens when you forget to clean them up.

Part 2: The idea

A feature flag is an if in your code whose answer comes from a setting instead of being written in the code:

checkout.mjs
import { isOn } from "./flags.mjs";

export function checkoutScreen(userId) {
  if (isOn("newCheckout", userId)) {
    return "new checkout";
  }
  return "old checkout";
}

Both checkouts are deployed. The setting decides, for each customer, which one runs, and you can change the setting without deploying anything.

Deploy is not release

The lifecycle lesson split deploy, putting code on servers, from release, letting users have it. Flags are what make that split real:

Without a flagWith a flag
The feature is live the moment the code is deployedThe code is deployed switched off. Nobody sees it
Unfinished work waits on a long branch, and merging hurtsUnfinished work is merged every day, switched off
Turning it off means a new deploy, or a new app versionTurning it off is changing a setting. It takes seconds
Everyone gets it at onceYou choose who gets it, and how many
Key point: A flag separates deploying code from releasing a feature. The code ships switched off, and a setting, not a deploy, decides who sees it.

What flags are used for

KindWhat it doesQuickCart example
Release flagHides unfinished or new work until it’s readyThe new checkout, merged daily for six weeks
Percentage rolloutGives a feature to a growing share of customers10%, then 50%, then everyone, watching errors each time
Kill switchTurns something off instantly when it misbehavesSwitching the new checkout off at 3am
TargetingGives a feature to chosen peopleThe QuickCart team first, before any customer

A percentage rollout is a canary release done with a flag instead of with servers.

How a percentage stays fair

To put 10% of customers in, the code turns each customer’s ID into a number from 0 to 99, called a bucket, using a hash: a calculation that always gives the same answer for the same input. Customers with a bucket under 10 get the feature.

The same customer always gets the same bucket. So they see the same checkout every time they open the app, and raising the rollout from 10% to 50% only adds people. Nobody has the feature taken away.

Where the setting lives

WhereChanging it needsGood for
A value written in the codeA deployNothing. That’s not really a flag
A file the app reads while it runsEditing the file on every serverLearning how flags work, as in the lab
A flag service, such as AWS AppConfig or a similar productA click or an API call. Every server picks it up within secondsReal use

Whatever holds the setting, the code must decide what happens when it can’t read it. The answer is always off: the old, tested behaviour.

Flag debt

Every flag doubles the number of ways the code can behave. Two flags make four combinations; ten make over a thousand. Once a feature has fully shipped, or been dropped, its flag is just a hidden branch that nobody tests.

RuleWhy
Every flag has an owner and a removal dateSomeone is responsible for cleaning it up
Remove the flag, and the losing code path, soon after full rolloutFewer hidden branches, less to test
Never reuse an old flag’s nameA reused name can switch on code nobody meant to run: the case above

Part 3: See it

The new checkout is deployed, switched off. Raise the rollout and watch which customers get it. Then give it a bug, and use the kill switch.

new-checkout rollout
The new checkout
0of 40 customers see the new checkout
0customers getting errors
0flag changes
0deploys needed
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40

Old checkoutNew checkoutNew checkout, getting errors

Deployed, switched offThe new checkout's code is already on the servers, but the flag is at 0%, so nobody sees it. Raise the rollout.

What to notice: the deploys needed counter never moves. Every change here, the rollout, the bug and the rescue, happened by changing a setting. And customers who had the new checkout at 10% still had it at 50%.

Part 4: Do it

Free — everything runs on your laptop.

You’ll build a tiny feature flag system in about 20 lines of JavaScript: a settings file, a percentage rollout that keeps customers in their bucket, a kill switch, and a safe default when the settings are broken. About 20 minutes.

  1. Install Node.js

    The code runs on Node.js, which runs JavaScript outside a browser. If node --version already prints a version, skip this step.

    Terminal
    brew install node
    PartWhat it means
    brew installdownload and install a tool
    nodeNode.js
    Terminal
    node --version
    PartWhat it means
    node --versionprint which version of Node.js is installed
    You should see
    v26.10.0

    Any version from 20 upwards works. Yours may differ.

  2. Make a folder

    Terminal
    mkdir quickcart-flags
    PartWhat it means
    mkdirmake a directory
    quickcart-flagsits name
    Terminal
    cd quickcart-flags
    PartWhat it means
    cdmove into a directory
    quickcart-flagsthe folder you just made
  3. Write the flag settings

    In your editor, create flags.json in that folder:

    flags.json
    {
      "newCheckout": {
        "enabled": true,
        "percent": 10
      }
    }
    SettingWhat it does
    newCheckoutthe flag’s name. The code asks for it by this name
    enabledthe kill switch. false turns the feature off for everyone, whatever the percentage
    percenthow many customers, out of 100, get the feature
  4. Write the flag code

    Create flags.mjs next to it:

    flags.mjs
    import { readFileSync } from "node:fs";
    import { createHash } from "node:crypto";
    
    // Read the flag settings. If the file is missing or broken, use no flags,
    // so every feature behind a flag is simply off.
    function loadFlags() {
      try {
        return JSON.parse(readFileSync("flags.json", "utf8"));
      } catch (err) {
        console.error("Could not read flags.json, so every flag is off: " + err.message);
        return {};
      }
    }
    
    // Turn a user ID into a number from 0 to 99. The same user always gets
    // the same number, so they stay in as the percentage grows.
    export function bucket(flagName, userId) {
      const hash = createHash("sha256").update(flagName + ":" + userId).digest();
      return hash.readUInt32BE(0) % 100;
    }
    
    export function isOn(flagName, userId) {
      const flag = loadFlags()[flagName];
      if (!flag || !flag.enabled) return false;
      return bucket(flagName, userId) < flag.percent;
    }
    PartWhat it does
    loadFlags()reads flags.json every time a flag is checked, so a change to the file takes effect straight away, with no restart
    try and catchif the file can’t be read or isn’t valid JSON, log a warning and return no flags. Everything falls back to off
    createHash("sha256")a hash: scrambles the text into a number, the same way every time
    flagName + ":" + userIdhash the flag name with the user, so each flag picks a different 10% of customers
    readUInt32BE(0) % 100take the first four bytes as a whole number, and keep the remainder after dividing by 100: a bucket from 0 to 99
    if (!flag || !flag.enabled) return falsean unknown flag, or one with the kill switch on, is off
    bucket(flagName, userId) < flag.percenton for customers whose bucket is under the percentage
  5. Write a script to try it

    Create try.mjs. It asks the flag about 20 customers:

    try.mjs
    import { bucket, isOn } from "./flags.mjs";
    
    let count = 0;
    for (let i = 1; i <= 20; i++) {
      const user = "user-" + i;
      const on = isOn("newCheckout", user);
      if (on) count++;
      const what = on ? "NEW checkout" : "old checkout";
      console.log(user.padEnd(8) + " bucket " + String(bucket("newCheckout", user)).padStart(2) + "  " + what);
    }
    console.log(count + " of 20 users see the new checkout");
    PartWhat it does
    for (let i = 1; i <= 20; i++)repeat for customers 1 to 20
    isOn("newCheckout", user)ask the flag whether this customer gets the new checkout
    padEnd and padStartadd spaces so the columns line up
  6. Run it at 10%

    Terminal
    node try.mjs
    PartWhat it means
    noderun a JavaScript file with Node.js
    try.mjsthe file to run
    You should see
    user-1   bucket 58  old checkout
    user-2   bucket 14  old checkout
    user-3   bucket 73  old checkout
    user-4   bucket 10  old checkout
    user-5   bucket 14  old checkout
    user-6   bucket 57  old checkout
    user-7   bucket 48  old checkout
    user-8   bucket 86  old checkout
    user-9   bucket  6  NEW checkout
    user-10  bucket 41  old checkout
    user-11  bucket 68  old checkout
    user-12  bucket 94  old checkout
    user-13  bucket 75  old checkout
    user-14  bucket 18  old checkout
    user-15  bucket 37  old checkout
    user-16  bucket 10  old checkout
    user-17  bucket 20  old checkout
    user-18  bucket  0  NEW checkout
    user-19  bucket 17  old checkout
    user-20  bucket 73  old checkout
    2 of 20 users see the new checkout

    You’ll see exactly these buckets: the hash always gives the same answer. Customers 9 and 18 have buckets under 10, so they get the new checkout. With only 20 customers, 10% won’t be exactly 2 every time; over thousands, it evens out.

    Run it again. Nothing changes: each customer gets the same answer every time.

  7. Roll out to 50%

    In flags.json, change "percent": 10 to "percent": 50 and save. Run the script again:

    Terminal
    node try.mjs
    PartWhat it means
    node try.mjsrun the script again, with the new setting
    You should see
    user-1   bucket 58  old checkout
    user-2   bucket 14  NEW checkout
    user-3   bucket 73  old checkout
    user-4   bucket 10  NEW checkout
    user-5   bucket 14  NEW checkout
    user-6   bucket 57  old checkout
    user-7   bucket 48  NEW checkout
    user-8   bucket 86  old checkout
    user-9   bucket  6  NEW checkout
    user-10  bucket 41  NEW checkout
    user-11  bucket 68  old checkout
    user-12  bucket 94  old checkout
    user-13  bucket 75  old checkout
    user-14  bucket 18  NEW checkout
    user-15  bucket 37  NEW checkout
    user-16  bucket 10  NEW checkout
    user-17  bucket 20  NEW checkout
    user-18  bucket  0  NEW checkout
    user-19  bucket 17  NEW checkout
    user-20  bucket 73  old checkout
    12 of 20 users see the new checkout

    Customers 9 and 18 still have it. Ten more joined. Nobody lost it. You changed a setting; you didn’t change or redeploy any code.

  8. Pull the kill switch

    In flags.json, change "enabled": true to "enabled": false. Leave percent at 50. Save, and run the script:

    Terminal
    node try.mjs
    PartWhat it means
    node try.mjsrun the script with the kill switch on
    The last line
    0 of 20 users see the new checkout

    Every line above it says old checkout. The percentage is still 50, but the kill switch wins. In a real service, this happens on the very next request.

  9. Break the settings file on purpose

    Set enabled back to true, and add a comma after 50, so the file ends like this. That comma makes it invalid JSON:

    flags.json, broken
        "percent": 50,
      }
    }

    Run the script:

    Terminal
    node try.mjs
    PartWhat it means
    node try.mjsrun the script with a broken settings file
    Each check prints a warning, then the old checkout
    Could not read flags.json, so every flag is off: Expected double-quoted property name in JSON at position 63 (line 5 column 3)
    user-1   bucket 58  old checkout

    The wording of the error may differ with your version of Node.js. The script prints the warning once for every customer, and ends with 0 of 20 users see the new checkout.

    Remove the comma again when you’re done.

  10. Plan the flag's removal

    A flag is temporary. Once the new checkout is at 100% and has been fine for a week or two, you would:

    DeleteWhere
    The if (isOn("newCheckout", userId)) checkWherever the code asks for the flag
    The old checkout codeThe branch of the if that no longer runs
    The newCheckout entryflags.json, or the flag service

    Write the removal date down now, next to the flag. That’s how flag debt is avoided.

  11. Clean-up step: Clean up

    Move out of the folder and delete it:

    Terminal
    cd ..
    PartWhat it means
    cd ..move up one level, out of quickcart-flags
    Terminal
    rm -r quickcart-flags
    PartWhat it means
    rm -rremove a folder and everything in it, straight away, with no bin
    quickcart-flagsthe folder to remove. Check the name before you press Return

    Keep Node.js installed. Nothing was created on AWS.

Part 5: Check yourself

What does a feature flag separate?

The new checkout is at 10% and customer 9 has it. You raise the rollout to 50%. Does customer 9 still have it?

The new checkout starts failing at 3am. What's the fastest safe fix, if it's behind a flag?

What is flag debt?

Your code can't read the flag settings, because the file is broken or the flag service is down. What should isOn return?

Part 6: Cheat sheet

On one card
IdeaIn one line
Feature flagAn if whose answer comes from a setting, not the code
Deploy vs releaseDeploy puts code on servers; the flag decides who gets the feature
Percentage rolloutA canary done with a flag. Buckets keep customers in as it grows
BucketA hash of the customer’s ID, from 0 to 99. Same customer, same bucket
Kill switchOff for everyone, on the next request, with no deploy
Safe defaultCan’t read the settings? Everything is off
Flag debtOld flags left in the code. Remove them soon after full rollout
NeverReuse an old flag’s name
Real flag servicesSuch as AWS AppConfig: change a flag with a click, every server follows

Finished the build and the quiz? Mark it done to fill in its stop on the route map.