Feature flags
Shipping code without switching it on.
Part 1: The problem
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:
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 flag | With a flag |
|---|---|
| The feature is live the moment the code is deployed | The code is deployed switched off. Nobody sees it |
| Unfinished work waits on a long branch, and merging hurts | Unfinished work is merged every day, switched off |
| Turning it off means a new deploy, or a new app version | Turning it off is changing a setting. It takes seconds |
| Everyone gets it at once | You choose who gets it, and how many |
What flags are used for
| Kind | What it does | QuickCart example |
|---|---|---|
| Release flag | Hides unfinished or new work until it’s ready | The new checkout, merged daily for six weeks |
| Percentage rollout | Gives a feature to a growing share of customers | 10%, then 50%, then everyone, watching errors each time |
| Kill switch | Turns something off instantly when it misbehaves | Switching the new checkout off at 3am |
| Targeting | Gives a feature to chosen people | The 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
| Where | Changing it needs | Good for |
|---|---|---|
| A value written in the code | A deploy | Nothing. That’s not really a flag |
| A file the app reads while it runs | Editing the file on every server | Learning how flags work, as in the lab |
| A flag service, such as AWS AppConfig or a similar product | A click or an API call. Every server picks it up within seconds | Real 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.
| Rule | Why |
|---|---|
| Every flag has an owner and a removal date | Someone is responsible for cleaning it up |
| Remove the flag, and the losing code path, soon after full rollout | Fewer hidden branches, less to test |
| Never reuse an old flag’s name | A 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.
- 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
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.
Install Node.js
The code runs on Node.js, which runs JavaScript outside a browser. If
node --versionalready prints a version, skip this step.Terminalbrew install nodePart What it means brew installdownload and install a tool nodeNode.js Terminalnode --versionPart What it means node --versionprint which version of Node.js is installed You should seev26.10.0Any version from 20 upwards works. Yours may differ.
Make a folder
Terminalmkdir quickcart-flagsPart What it means mkdirmake a directory quickcart-flagsits name Terminalcd quickcart-flagsPart What it means cdmove into a directory quickcart-flagsthe folder you just made Write the flag settings
In your editor, create
flags.jsonin that folder:flags.json{ "newCheckout": { "enabled": true, "percent": 10 } }Setting What it does newCheckoutthe flag’s name. The code asks for it by this name enabledthe kill switch. falseturns the feature off for everyone, whatever the percentagepercenthow many customers, out of 100, get the feature Write the flag code
Create
flags.mjsnext to it:flags.mjsimport { 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; }Part What it does loadFlags()reads flags.jsonevery time a flag is checked, so a change to the file takes effect straight away, with no restarttryandcatchif 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 Write a script to try it
Create
try.mjs. It asks the flag about 20 customers:try.mjsimport { 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");Part What 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 padEndandpadStartadd spaces so the columns line up Run it at 10%
Terminalnode try.mjsPart What it means noderun a JavaScript file with Node.js try.mjsthe file to run You should seeuser-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 checkoutYou’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.
Roll out to 50%
In
flags.json, change"percent": 10to"percent": 50and save. Run the script again:Terminalnode try.mjsPart What it means node try.mjsrun the script again, with the new setting You should seeuser-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 checkoutCustomers 9 and 18 still have it. Ten more joined. Nobody lost it. You changed a setting; you didn’t change or redeploy any code.
Pull the kill switch
In
flags.json, change"enabled": trueto"enabled": false. Leavepercentat 50. Save, and run the script:Terminalnode try.mjsPart What it means node try.mjsrun the script with the kill switch on The last line0 of 20 users see the new checkoutEvery 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.Break the settings file on purpose
Set
enabledback totrue, and add a comma after50, so the file ends like this. That comma makes it invalid JSON:flags.json, broken"percent": 50, } }Run the script:
Terminalnode try.mjsPart What it means node try.mjsrun the script with a broken settings file Each check prints a warning, then the old checkoutCould 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 checkoutThe 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.
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:
Delete Where The if (isOn("newCheckout", userId))checkWherever the code asks for the flag The old checkout code The branch of the ifthat no longer runsThe newCheckoutentryflags.json, or the flag serviceWrite the removal date down now, next to the flag. That’s how flag debt is avoided.
Clean-up step: Clean up
Move out of the folder and delete it:
Terminalcd ..Part What it means cd ..move up one level, out of quickcart-flagsTerminalrm -r quickcart-flagsPart What 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
| Idea | In one line |
|---|---|
| Feature flag | An if whose answer comes from a setting, not the code |
| Deploy vs release | Deploy puts code on servers; the flag decides who gets the feature |
| Percentage rollout | A canary done with a flag. Buckets keep customers in as it grows |
| Bucket | A hash of the customer’s ID, from 0 to 99. Same customer, same bucket |
| Kill switch | Off for everyone, on the next request, with no deploy |
| Safe default | Can’t read the settings? Everything is off |
| Flag debt | Old flags left in the code. Remove them soon after full rollout |
| Never | Reuse an old flag’s name |
| Real flag services | Such as AWS AppConfig: change a flag with a click, every server follows |