Feature flags vs remote config vs CMS: three questions
Shipping without a releaseDeep dive10 min readMiniwall team
Part of the series: Ways to change a live app without shipping a release
On this page
Use a feature flag when the question is who sees this, and when. Use remote config when the question is what value this should be. Use a CMS when the question is who writes this, and who approves it. The trouble starts when one of them gets asked to answer another's question, and that happens easily because all three look the same from the outside: something that isn't in your build changes what the app does.
Steps in order, with who does each: The question or The usual tool.
- The question: Who sees this, and when?
- The usual tool: Feature flag
- The question: What should the value be?
- The usual tool: Remote config
- The question: Who writes the words, and who approves them?
- The usual tool: CMS
That's the short version. The rest of this post is about the places where it stops being short: where the three overlap, how each one tends to be abused, and why flags are the only one of the three that you have to plan the end of. If you're still deciding whether any of these is the right family of tool, the wider comparison of ways to change a live app puts them next to staged releases, OTA updates and server-driven screens.
A flag answers: who gets this, and when?
A flag is a branch in your code with both sides shipped. At runtime something decides which side a given person takes. Hold on to "a given person", because that's what separates a flag from the other two. OpenFeature, a specification for a vendor-agnostic flagging API[3], models it as an evaluation context, "ambient information for the purposes of flag evaluation", which can be the basis for targeting[2]. A percentage rollout, staff only, everyone except one country: all of those are versions of "for whom".
We find it useful to split flags by why they exist. This split is our own shorthand, not a standard.
- Release flags hide unfinished or risky work. They live days to weeks.
- Experiment flags put people into groups. They live as long as the test.
- Ops flags turn something off in an emergency. They can stay for years, and they should, as long as the off state is tested.
- Entitlement flags decide what a customer is allowed to use.
The first two should die. The third should be boring and well tested. The fourth probably isn't a flag at all. Who is allowed to use what is customer data, and we'd keep it where billing state lives, with a history of who changed it, and let the code behind a flag read it if it must. A dashboard that anyone on the product team can flip is a poor place to decide who gets paid features for free.
A flag is also the wrong tool when only one path exists. It chooses between code that already shipped. And it's the wrong tool when you can't say how long it will live.
Remote config answers: what should this value be?
Remote config is a key-value store the app reads, with defaults baked into the build. The code already knows the key and what to do with the value. The server only changes the value.
Firebase's JS SDK shows the pattern in plain code. The in-app defaults are an "Object containing default values for configs", typed as strings, numbers or booleans[5]. When code asks for a key, the SDK looks at the config it fetched and activated first, then at the in-app default, and only then returns a static fallback and logs a debug message telling you to define a default or remote value[4]. Fetching and activating are two separate steps: one fetches and caches, the other makes the last fetched config available to the getters[4].
Two things in that design are worth copying whatever you use. A value always has a local fallback, so a failed fetch is boring. And a new value doesn't have to take effect the instant it arrives. Our rule of thumb, and it's an opinion, is that anything a person is looking at shouldn't change under them.
Good fits: a timeout, a limit, a threshold, an endpoint URL, a single line of copy, the number of items on a shelf. The code should also decide what a legal value is. Don't trust the server to be right:
// Illustration only, not any product's API.
const raw = config.number("shelf_max_items", 12); // 12 ships in the build
const shelfMax = Math.min(Math.max(raw, 4), 30); // the code decides what is legalOne catch: Math.min and Math.max pass NaN straight through, so a value that isn't a number (a string, a missing key) would come out as NaN; check Number.isFinite(raw) first and fall back to the build's default.
Remote config is the wrong tool when the value is really a program, and this is the misuse we'd worry about most.
// Illustration of what to avoid.
const rule = config.string("upgrade_rule", "plan == 'free' && sessions > 3");
if (evaluate(rule, user)) showUpgrade();That string is a small programming language with no types, no tests and no review, and it deploys the moment someone saves it. A typo is a production incident without a pipeline. If the decision differs per person, it's a flag. If it's a threshold, keep the logic in code and make only the number remote: sessions > config.number("upgrade_after_sessions", 3).
It's also the wrong tool for whole screens spread across dozens of keys. You've built a layout format with no schema. Server-driven UI does that on purpose, with the checks that come with it.
A CMS answers: who writes this, and who approves it?
A CMS is for content that people who aren't developers edit: drafts, review, scheduling, translations, a history of who changed what. The app fetches entries and renders them inside layouts it already has. The content has a shape (a title, a body, an image with alt text), and the developers own that shape.
It fits when the text is longer than a string, there are many entries, there's more than one language, or an editor needs to approve before anything goes live. If you have five strings and one developer, it's more machinery than you need and remote config is enough.
It's the wrong tool for behavior. Watch for the moment when content entries grow fields like show_if_plan, min_app_version or priority. Each of those is a hidden condition. Writers are now shipping logic through a form built for sentences, and nobody can see the whole set of rules because they're scattered over entries. That's a flag system with no tooling, and the person who wrote the copy is now responsible for who sees it.
And it can't move the layout. A CMS makes the copy editable, not the screen. Plan for the content not arriving too: ship a bundled or cached fallback, the same way config ships a default.
Where the lines blur
The tools don't draw the boundary for you. OpenFeature's specification requires typed flag evaluation for boolean, numeric, string and structure values[1], so a flag API can hand back a number, which is exactly what remote config does. The questions draw the line, not the product.
Three misuses are easy to fall into. These are patterns we'd watch for, not measurements.
A flag turned into config. The flag started as a boolean. Then it became a string with seven allowed values and a switch in the code. Now nobody can say which of the seven are still in use. If the value is a number or a label, make it config and keep flags boolean.
Config turned into logic. The rule-string example above. Another version: a JSON blob of conditions that the app interprets. Every extra clause is code that bypassed review.
A CMS turned into a behavior panel. Fields that decide who sees an entry, and when. There is a right tool for that, and it's a flag with an owner.
The reverse mistakes happen too. A flag per campaign, with the banner text stored in the flag, is content wearing a flag's clothes. And copy hard-coded behind a flag means a developer has to ship the next headline.
Flags are the ones that cost you later
Config keys and CMS entries can go stale too, though in our view the damage is usually smaller. A flag leaves a dead branch in the code. Uber's Piranha README says the tool is "mostly used to clean up stale feature flags" at Uber, and lists the drawbacks of leaving them: "Presence of unused code in the source as well as the binary", and "Stale flags can also cause bugs."[6] That's a company building a code-transformation tool just to delete flags, which tells you the cleanup is real work.
There's arithmetic too, and you can check it yourself. Ten independent boolean flags give 1,024 combinations. Most are never reachable, but the code doesn't know that, and nobody has tested them.
Before
No end date
- Add flag
- Ship
- Roll out to everyone
- Nobody deletes it
After
End date set on day one
- Add flag with owner and date
- Ship
- Roll out to everyone
- Delete flag and the losing path
What we'd write down at the moment a flag is created, because this is the cheapest time to ask:
// Illustration of a flag record, not any product's schema.
const flag = {
key: "new-checkout",
kind: "release", // release | experiment | ops | entitlement
owner: "payments-team",
createdOn: "2026-10-26",
removeBy: "2026-12-01", // a date, not "later". Ops flags say why they stay.
default: false, // what the app does when the value can't be fetched
};That last field matters for all three tools. OpenFeature puts it in a single requirement: "Flag evaluation calls must always return the default value in the event of abnormal execution."[1] Whatever you pick, ask what the app does when the network is down, and write the answer in code.
Who owns each one
| Aspect | Feature flag | Remote config | CMS |
|---|---|---|---|
| Answers | Who gets which path, and when | What value the code uses | What the content says |
| Owner | The team that owns the feature | The team that owns the code reading the key | Editors; developers own the content model |
| Who changes it | Whoever can say what happens if it goes wrong | Developers, or others within limits the code enforces | Editors, with review |
| Expected life | Days to weeks, except ops flags | As long as the key is read | As long as the content is true |
| What goes wrong | Dead paths, untested combinations | Values the code never expected | Stale text, fields that act as logic |
| Fallback | The default in code | The in-app default | Cached or bundled copy |
Swipe sideways to see every column.
The "who changes it" row is where we'd push hardest. The person who flips a switch should be able to say what happens if it goes wrong and how to flip it back. That's often a developer for a release flag and a product owner for a campaign, and rarely the same person for both.
Five questions, in order
- Does the answer differ per person or per group? Then it's a flag.
- Is it one value the code already reads? Then it's config, and the code should still decide the legal range.
- Does a non-developer need to edit it, with review? Then it's a CMS, with a content model that developers own.
- Does the change need a code path that isn't in the build? None of the three can do that. It's a release, and a flag can hide it until you're ready.
- Is it the arrangement of a screen? None of the three fits. See what server-driven UI is.
If two answers seem to fit, take the one with the shortest life and the smallest blast radius.
When to use none of them
If you ship once a day, the same people write the code and the copy, and nobody is waiting on you, a deploy is simpler than any of this. A tool you add is a tool you maintain, and flags in particular charge interest.
And don't use any of the three as a security boundary. Whatever reaches the device, treat as readable by the person holding it. A flag in the client that hides a paid feature hides it from the interface and nothing else. Enforce access on the server.
If the change you have isn't a switch, a value or a paragraph, start with the list of ways to change a live app without shipping a release and work down from the lightest option.
Sources
- OpenFeature specification, section 1: flag evaluation (opens in a new tab)github.com · accessed
- OpenFeature specification, section 3: evaluation context (opens in a new tab)github.com · accessed
- OpenFeature specification repository (README) (opens in a new tab)github.com · accessed
- Uber, Piranha repository (README on stale feature flags) (opens in a new tab)github.com · accessed


