If all you need is "turn this code path on for everyone or nobody," a feature_flags table plus a cached lookup will serve you for years and cost nothing. You start paying for a managed service when non-engineers need to flip flags, when you need percentage rollouts targeted at stable user buckets, and when someone has to answer "who turned that on, and when?" The trap in both directions is the same: teams underestimate the failure mode where the flag layer is unreachable, and they underestimate how much dead flag code accumulates.
What does a flag service actually sell you?
Not the boolean. Evaluating a boolean is five lines of code. What you're buying, roughly in order of how much they matter to a small team:
- A UI safe enough for someone who can't deploy. The whole point of a kill switch is that it works at 2am when the person on call isn't the person who wrote the feature.
- An audit trail. Who changed which flag, when, from what value. This is the thing you'll miss the first time a flag change causes an incident and nobody can reconstruct the timeline.
- Consistent bucketing across services. A user in the 10% rollout in your API must land in the same bucket in your web frontend and your background worker. Get that wrong and you get the "the feature flickers on and off for one customer" bug.
- Real-time propagation and targeting beyond percentages. Streaming updates so a flip takes effect in seconds, plus per-plan, per-org, per-region rules.
The first two are an afternoon of work. The last two are where homegrown implementations quietly rot.
Takeaway: you're not paying for flag evaluation, you're paying for safe non-engineer access and an audit trail you'll only value in hindsight.
What happens when the flag service is unreachable?
This is the failure mode that turns a cost decision into an availability decision, and it's the one I've actually been burned by. The symptom looks like this in your logs:
[warn] LaunchDarkly client initialization timed out after 5000ms
[warn] feature flag "new_checkout" evaluated to fallback value: false
Two independent things go wrong. First, SDK initialization is usually awaited during boot, so an unreachable provider adds its full timeout to startup — which, in a rolling deploy with a readiness probe, looks like a failed deploy rather than a flag problem. Second, when init fails every flag returns the fallback passed at the call site, and in most codebases half of those were typed as false without thinking. If false means "use the old code path," fine. If false means "disable the rate limiter," you've just had an outage caused by your flag vendor.
The fix has nothing to do with which vendor you choose:
// flags.js — never let the flag provider decide whether your app starts.
const DEFAULTS = require('./flag-defaults.json'); // committed to the repo
let snapshot = { ...DEFAULTS };
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('flag init timeout')), ms).unref()
),
]);
}
async function initFlags(client) {
try {
// whatever your SDK calls its "ready" promise
await withTimeout(client.ready(), 500);
snapshot = await client.allFlags();
} catch (err) {
console.warn('flags: serving committed defaults', { reason: err.message });
}
}
function flag(key) {
return key in snapshot ? snapshot[key] : DEFAULTS[key] ?? false;
}
module.exports = { initFlags, flag };
Three properties matter: a short, explicit init timeout; a defaults file in version control, so the fallback is a reviewed decision; and a boot sequence that continues regardless. Most SDKs cache their last known ruleset in memory and ride out a network blip — but only if the process already initialized, which is exactly what doesn't hold during a deploy.
Takeaway: pick your fallback values deliberately and commit them to the repo, because a flag provider outage will read them all at once.
When is a managed flag service worth paying for?
| Signal | Config table you own | Managed service |
|---|---|---|
| Only engineers flip flags | Fine | Overkill |
| Support or product needs to flip flags | Painful (you build the UI) | This is the product |
| Boolean on/off only | Fine | Overkill |
| Percentage rollouts across multiple services | Doable, easy to get subtly wrong | Solved, consistent bucketing |
| Need "who changed what, when" | You build audit logging | Built in |
| Flags read on hot paths (per-request) | Cache locally, trivial | Needs local eval or a proxy |
| Client-side / mobile flags | You build a public endpoint | Solved, with edge caching |
| Compliance or SSO requirements | Your problem | Usually an upper-tier feature |
| Fewer than ~10 live flags at a time | Fine | Hard to justify |
Two rows deserve emphasis. Client-side flags are where homegrown solutions get expensive: you now need a public, cacheable, low-latency endpoint that doesn't leak targeting rules to the browser. And pricing — as of mid-2026 the common models are per-seat, per-monthly-active-context, or per-request for edge evaluation. Check which shape your usage is: a two-person team with millions of anonymous visitors gets a very different bill under a per-context model than under a per-seat one.
Takeaway: the moment a non-engineer needs to flip a flag, or a flag has to reach the browser, the build-your-own math stops working.
What does the homegrown version actually look like?
Smaller than people expect, if you keep the scope honest. A table:
create table feature_flags (
key text primary key,
enabled boolean not null default false,
rollout_percent int not null default 0
check (rollout_percent between 0 and 100),
updated_at timestamptz not null default now(),
updated_by text
);
And deterministic bucketing, which is the part worth getting right:
const crypto = require('crypto');
function inRollout(flagKey, userId, percent) {
if (percent >= 100) return true;
if (percent <= 0) return false;
// Hash the flag key with the user id so a user isn't in the same
// bucket for every flag, and so the bucket is stable across services.
const digest = crypto.createHash('sha256')
.update(`${flagKey}:${userId}`)
.digest();
return digest.readUInt32BE(0) % 100 < percent;
}
Hashing flagKey:userId rather than userId alone matters: if you hash the user id only, the same unlucky 10% of users are the guinea pigs for every single rollout you ever do. Cache the table in memory with a short TTL, refresh in the background, and never block a request on the database read.
What the homegrown version does not give you is flag hygiene. Dead flags are the real cost: every stale flag is a branch no test covers and no one dares delete. Run something like this in CI and diff it against your flag store:
git grep -oh "flag('[a-z0-9_.-]*'" -- src | sort -u
Takeaway: a homegrown flag system is a weekend to build and a permanent tax to clean up, and the cleanup is the part teams skip.
How do the main options differ as of mid-2026?
If you want the mature, safe default with the deepest targeting and experimentation tooling, LaunchDarkly is the option that handles streaming updates, per-environment audit trails, and a self-hosted relay for high-volume evaluation — with the caveat that it is the priciest of the group and the relay is extra infrastructure you operate. If you want an open-source core you can self-host on your own Postgres and pay for only when you need SSO and richer access control, Unleash is the one designed to run in your infrastructure, though front-end flags require running its edge/proxy component so targeting rules never reach the browser. If you want open source with a hosted option and a simpler mental model that includes remote config alongside flags, Flagsmith covers that ground, with a smaller ecosystem and fewer third-party integrations than LaunchDarkly. If your team already runs PostHog for product analytics, its built-in flags are worth using before adding a second vendor, keeping in mind flags are a secondary product there rather than the main event.
Whichever you pick, wrap it behind OpenFeature, the vendor-neutral flag SDK standard under the CNCF, so a later migration is a provider swap rather than a codebase-wide refactor. The abstraction is thin, but provider maturity varies by language — check your runtime before committing.
Takeaway: choose on hosting model and access control first, because targeting features have largely converged across these tools.
FAQ
Should I use a database table for feature flags?
Yes, if only engineers flip flags, you have a handful of them, and they're boolean. Cache the table in memory with a short TTL so you're not adding a query to every request, and add an updated_by column from day one — reconstructing who flipped what is the first thing you'll want during an incident.
What happens if LaunchDarkly or my flag provider goes down?
Initialized SDK clients keep serving their last cached ruleset, so running processes usually survive a provider outage. The dangerous window is process startup: if the client can't initialize, every flag falls back to the default value at its call site, so commit an explicit defaults file and set a short init timeout instead of blocking boot.
How many feature flags is too many?
There's no universal number, but if you can't name what every live flag is currently gating, you have flag debt. Give each flag an owner and a removal date at creation time, and treat a permanently-on flag as a code change you owe the repo, not a setting.
Bottom line
If you're a small team where engineers own the flips and flags are on/off, build the table — it's an afternoon, and a managed service would mostly bill you for a UI nobody opens. Move to a managed service when support or product needs to flip flags without a deploy, when you need consistent percentage bucketing across more than one service, or when flags have to reach the browser. Self-host Unleash or Flagsmith if you'd rather spend ops time than budget; take LaunchDarkly if targeting depth and audit trails matter more than the invoice. Either way, set explicit fallback defaults and a removal date per flag on the day you create it — that discipline matters more than which vendor's logo is on the dashboard.
Top comments (0)