You want to merge a half-finished checkout redesign into main without breaking checkout for everyone. You want to ship a risky billing change but keep a kill switch in case it misbehaves at 2am. You want to turn a new dashboard on for one beta customer and nobody else. The first instinct, reading the docs, is to reach for LaunchDarkly or Flagsmith or Split. But for a small team or an early-stage SaaS, feature flags without LaunchDarkly is not a compromise — it's about a hundred lines of code you fully own.
This is the same kind of decision I keep helping founders make: which piece of infrastructure to actually buy, and which to build because the build is small and the buy is a recurring tax. Feature flags, for a team that has eight of them, land firmly on the build side. Let me show you why, and then show you the code.
What Feature Flags Actually Buy You
Strip away the marketing and a feature flag is one thing: a runtime switch that decides whether a piece of code runs, without redeploying. That single capability unlocks several distinct workflows, and it's worth being precise about them because they're often blurred together.
Decoupling deploy from release. Today, for most teams, deploying code and releasing a feature are the same event — the moment the new bundle goes live, users get the new behaviour. Flags split those apart. You deploy the code dark on Tuesday, verify it's healthy in production, and flip it on for users on Thursday. The deploy is a low-stress engineering event; the release is a separate, deliberate product decision.
Kill switches for risky code. A new payment path, a rewritten search index, a third-party integration you don't fully trust yet — wrap it in a flag and you have an off switch that doesn't require a rollback deploy. When something misbehaves, you flip the flag instead of reverting commits and waiting for CI. Mean-time-to-recovery drops from "however long a deploy takes" to "a database write."
Gradual percentage rollout. Instead of shipping a change to 100% of users at once, you turn it on for 5%, watch your error rates and latency, then 20%, then 50%, then everyone. If something breaks, it broke for 5% of traffic, not all of it. This is the feature that's genuinely fiddly to build correctly, and most of the technical interest in this article lives here.
Per-user and per-tenant targeting. Turn a feature on for one specific beta tenant, your own internal accounts, or everyone on the enterprise plan — regardless of the rollout percentage. This is how you dogfood, how you run private betas, and how you honour "customer X explicitly asked to opt out."
Trunk-based development. You can merge unfinished work into main behind a flag that defaults to off. The code is in the codebase, getting integrated and built continuously, but it's inert until you decide otherwise. This kills long-lived feature branches and the merge hell that comes with them.
That's the value. Notice that none of it inherently requires a vendor.
Why a SaaS Is Often the Wrong First Choice
I want to be fair here, because LaunchDarkly is a genuinely good product and there's a point where it earns its price. But for an early-stage team, buying it first is usually backwards, for four concrete reasons.
Cost that scales with the wrong thing. Flag platforms price on seats and monthly active users — the very numbers a growing product wants to grow. You start paying more precisely as you succeed, for a capability whose complexity didn't change. A team with eight flags is paying a per-MAU rate for infrastructure they could express in one database table.
A network dependency in your hot path. This is the one engineers underestimate. Every flag evaluation is conceptually a question — "is feature X on for this user?" — and a SaaS answers it either via a remote call or via an SDK that has to initialize, stream updates, and stay in sync. Their SDKs work hard to make this fast and local, but you've still introduced a third-party system into the path of rendering your pages. When their edge has a bad day, or the SDK fails to init, you need a sane fallback — and now you're writing flag-evaluation fallback logic anyway.
Data-residency and privacy surface. To do per-user targeting, the platform needs to know about your users — identifiers, attributes, sometimes more. For an EU product that's another processor in your data-flow diagram, another DPA to sign, another thing your privacy policy has to account for. Keeping flag evaluation inside your own Postgres sidesteps all of it.
Massively over-featured for where you are. Audit logs, approval workflows, multivariate experiments, twelve-attribute segmentation, a polished UI for non-engineers — that's a lot of product. It's the right product for a 60-engineer org with PMs flipping flags. It's dead weight for a three-person team whose "flag UI" can be a SQL UPDATE.
None of these are dealbreakers forever. They're reasons not to start there.
The 100-Line Solution
Here's the whole design. A Postgres table holds the flags. A small evaluation function answers isEnabled(key, context). An in-memory cache keeps you off the database on the hot path. That's it.
I'm using Postgres-backed flags as the primary version on purpose, because the entire point of a flag is to flip it without a deploy. If your flags live in a TypeScript config file, changing one means a commit, a build, and a deploy — which defeats the kill-switch and gradual-rollout use cases. A static config is fine for the simplest case (a permanent on/off toggle that rarely changes), and I'll note that variant at the end, but the database version is the one that earns its keep.
The flags table
CREATE TABLE feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage SMALLINT NOT NULL DEFAULT 0
CHECK (rollout_percentage BETWEEN 0 AND 100),
enabled_tenants TEXT[] NOT NULL DEFAULT '{}',
disabled_tenants TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
The columns map directly to the use cases above. enabled is the master switch — false means off for everyone, full stop, which is your kill switch. rollout_percentage drives gradual rollout. enabled_tenants and disabled_tenants are the targeting allow-lists: a tenant in enabled_tenants gets the feature regardless of percentage, and a tenant in disabled_tenants never gets it regardless of percentage. (For per-user rather than per-tenant targeting, the same columns hold user IDs — the evaluation logic is identical; pick whichever identifier your product keys on.)
Deterministic percentage rollout
This is the part worth slowing down for. The naive way to roll a flag out to 20% of users is to roll a die per request:
// ANTI-PATTERN: do not do this — recomputes per request
function isInRollout(percentage: number): boolean {
return Math.random() * 100 < percentage;
}
That's broken in a way that's easy to miss. The same user gets a different answer on every request — feature on, page reload, feature off, reload, feature on again. The UI flickers, sessions are inconsistent, and your error rates become impossible to attribute. A 20% rollout has to mean "a stable 20% of users always get it," not "every request has a 20% chance."
The fix is to make the decision a deterministic function of the user and the flag, with no randomness at request time. Hash flagKey + userId into a number in [0, 100) and compare it to the rollout percentage. Same user, same flag, same answer — forever, until you change the percentage. This is the same deterministic-hashing trick I used to make a viral web toy wrong the same way every time: unpredictable to a human, perfectly reproducible from the input alone.
You need a fast, well-distributed hash — not a cryptographic one, since this isn't a security boundary, just a bucketing function. FNV-1a is a good fit: tiny, fast, and spreads inputs evenly across the output range.
// FNV-1a, 32-bit. Small, fast, well-distributed — not cryptographic.
function fnv1a(input: string): number {
let hash = 0x811c9dc5; // FNV offset basis
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
// 32-bit FNV prime multiply, kept in uint32 range
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash >>> 0;
}
// Map any string to a stable bucket in [0, 100).
function bucket(flagKey: string, userId: string): number {
// Salt with the flag key so a user isn't always in the same
// percentile across every flag — otherwise the unlucky 5% of
// user A's first rollout are the unlucky 5% of every rollout.
return fnv1a(`${flagKey}:${userId}`) % 100;
}
The salting detail matters more than it looks. If you hash the user ID alone, the user who lands in bucket 3 is in bucket 3 for every flag — so the same unlucky cohort is always first into every rollout, and your "20% of users" are always the same 20% of users across unrelated features. Mixing the flag key in re-shuffles the buckets per flag, so each rollout samples an independent slice.
The evaluation function
Now the whole decision, in the order the rules apply:
type FlagRecord = {
key: string;
enabled: boolean;
rolloutPercentage: number;
enabledTenants: string[];
disabledTenants: string[];
};
type Context = {
tenantId?: string; // or userId — whatever you bucket on
};
function evaluate(flag: FlagRecord | undefined, ctx: Context): boolean {
// Unknown flag → off. Fail closed, never crash on a typo'd key.
if (!flag) return false;
// Master kill switch wins over everything.
if (!flag.enabled) return false;
const id = ctx.tenantId;
// Explicit overrides beat the percentage roll, both directions.
if (id && flag.disabledTenants.includes(id)) return false;
if (id && flag.enabledTenants.includes(id)) return true;
// No identity to bucket on → treat as a plain on/off at 100%.
if (!id) return flag.rolloutPercentage >= 100;
// Deterministic percentage rollout.
return bucket(flag.key, id) < flag.rolloutPercentage;
}
Read the order of the rules, because the order is the semantics. Kill switch first, then explicit per-tenant overrides (force-off beats force-on by convention — the safer direction wins ties), then the percentage bucket. An unknown flag key returns false rather than throwing: a typo in a flag name should make the feature quietly stay off, never take down the request. That fail-closed default is deliberate, and it's the kind of small decision that separates a flag system you can trust from one that becomes its own source of incidents.
Caching: stay off the database
If evaluate hit Postgres on every flag check, you'd add a query to the hot path of every request — exactly the network dependency I criticized the SaaS for. So you don't. Load all flags into memory once, refresh on an interval, and serve every evaluation from the in-memory snapshot.
import type { Pool } from "pg";
const REFRESH_MS = 30_000; // flips propagate within this window
export class FlagStore {
private cache = new Map<string, FlagRecord>();
constructor(private pool: Pool) {}
async start(): Promise<void> {
await this.refresh();
// unref so the timer never keeps the process alive on shutdown
setInterval(() => {
this.refresh().catch((err) => console.error("flag refresh failed", err));
}, REFRESH_MS).unref();
}
private async refresh(): Promise<void> {
const { rows } = await this.pool.query<FlagRecord>(
`SELECT key,
enabled,
rollout_percentage AS "rolloutPercentage",
enabled_tenants AS "enabledTenants",
disabled_tenants AS "disabledTenants"
FROM feature_flags`
);
const next = new Map<string, FlagRecord>();
for (const row of rows) next.set(row.key, row);
this.cache = next; // atomic swap — readers never see a half-built map
}
isEnabled(key: string, ctx: Context = {}): boolean {
return evaluate(this.cache.get(key), ctx);
}
}
Call flags.start() once at boot, then flags.isEnabled("new_checkout", { tenantId }) anywhere — it's a synchronous map lookup plus an integer hash, with no I/O. Note the failure handling: a failed refresh logs and keeps serving the previous snapshot, so a transient database blip degrades to slightly-stale flags rather than an outage. And building the new map fully before swapping it in means a reader mid-refresh sees either the complete old state or the complete new state, never a partial one.
The honest tradeoff is right there in REFRESH_MS: a flag flip takes up to one refresh interval to propagate to every running instance. At 30 seconds, flipping a kill switch in SQL takes up to half a minute to fully take effect across your fleet. For most teams that's completely fine — half a minute to disable a misbehaving feature is still dramatically faster than a rollback deploy. If you genuinely need sub-second propagation, that's a real reason to want something more, which is the next section. (You can also shorten the interval or add a LISTEN/NOTIFY push to invalidate on write — but now you're adding lines, and the whole pitch was that this stays small.)
That's the system. The table, the hash, the evaluation function, and the cached store come to roughly a hundred lines, and you own every one of them.
slug="mvp-development"
text="Building an MVP and weighing build-versus-buy on every piece of infrastructure? Knowing which 100-line solution beats a subscription — and which doesn't — is exactly the kind of early call I help founders get right."
/>
When You Should Actually Buy LaunchDarkly
I'm not going to pretend the 100-line version scales to every org, because it doesn't, and pretending otherwise would be the kind of dishonesty that makes the rest of this article less trustworthy. There's a clear line where a SaaS earns its price. You're over it when:
- Non-engineers need to flip flags themselves. The moment a PM or a marketing lead wants to turn a feature on without a developer running SQL, you need a real UI with roles and permissions. Building and maintaining that UI is its own product — buy it.
-
You need a rich audit trail with approvals. Who changed which flag, when, why, and who approved it. Regulated industries and larger orgs need this for compliance, not vanity. An
updated_atcolumn doesn't cut it. - Segmentation gets genuinely complex. Targeting by plan tier and country and signup date and twelve other attributes, with reusable segments — that's a rules engine, and writing your own rules engine is exactly the over-engineering this article argues against, just in the other direction.
- You need true real-time propagation. Sub-second streaming updates instead of a polling interval. If a flag flip has to reach every client in under a second, a vendor's streaming SDK is built for it and your 30-second poll isn't.
- You're running real experiments. Multivariate testing with built-in statistical significance, conversion tracking, and guardrail metrics. That's an experimentation platform, not a flag system, and it's a lot to build well.
If you need those things, LaunchDarkly is worth every euro. The point of the 100-line version isn't that the SaaS is bad — it's that most early-stage teams have none of these needs yet, and buying a platform to solve problems you don't have is how MVPs accrete cost and complexity before they've found product-market fit. Build the small thing now; buy the big thing when the big thing's problems are actually yours.
This is also why feature flags fit so naturally into a multi-tenant SaaS schema: the per-tenant allow-lists above are just another tenant-scoped concern, evaluated against the same tenant_id you're already threading through everything. And if you're standing up a new product, the flag table slots cleanly into the broader build-a-SaaS-with-Next.js checklist — it's a small, early piece of plumbing that pays for itself the first time you need to ship something dark.
The static-config variant
For completeness: if a flag is a permanent on/off you change maybe twice a year, you don't even need the table. A typed config object works:
// Simplest case only — changing a flag here requires a deploy.
const FLAGS = {
newCheckout: false,
legacyExportApi: true,
} as const;
export const isEnabled = (key: keyof typeof FLAGS): boolean => FLAGS[key];
It's type-safe, it's zero-infrastructure, and it's honest about its one limitation: flipping a flag means a deploy. That rules out kill switches and gradual rollout, which is most of the value. Use it for the genuinely static toggles and use the database version for everything that needs to move at runtime.
Takeaways
- Feature flags decouple deploy from release — kill switches, gradual rollout, per-tenant targeting, and trunk-based development all fall out of one runtime switch.
- For a small team, building beats buying. A SaaS prices on MAU, adds a dependency to your hot path, and ships features you won't use for years.
-
Deterministic percentage rollout is the one non-trivial idea. Hash
flagKey + userIdto a stable bucket — neverMath.random(), or the same user flickers on and off every request. - Salt the hash with the flag key so each rollout samples an independent slice instead of always picking on the same unlucky cohort.
- Cache in memory, refresh on an interval, and own the tradeoff: flips propagate within one refresh window, which is still far faster than a rollback deploy.
- Fail closed. An unknown flag returns off and never throws; a failed refresh serves the last good snapshot.
- Buy the SaaS when its problems are actually yours — non-engineers flipping flags, audit trails with approvals, complex segmentation, sub-second propagation, or real experimentation. Most early teams have none of these yet.
Top comments (2)
This is a strong small-system design, especially the atomic cache swap and deterministic bucketing. One edge case is worth making explicit: “keep serving the previous snapshot” is resilient for ordinary flags, but it can defeat the safety semantics of a kill switch. If the database or refresh path fails during the same incident, a risky payment path may remain enabled indefinitely.
I would classify flags by stale-cache policy. Cosmetic flags can serve last-known state; safety-critical flags should carry a max-staleness value and evaluate to off once the last successful refresh is too old. Expose last-refresh age as a metric, alert on instance skew, and test a disable operation while one process cannot refresh. LISTEN/NOTIFY can reduce normal propagation time, but the bounded-staleness rule is what preserves the kill switch when push and polling both fail.
Handling cache invalidation for per-tenant overrides is usually where these custom implementations get tricky. When you have a global flag and a tenant-specific override, do you invalidate the entire cache on a global update, or do you use a composite cache key combining the tenant ID and flag name? I have found that using a composite key with a short TTL works well for most SaaS apps, but it can cause brief inconsistencies during active rollouts. Have you considered adding a pub/sub mechanism via Postgres logical decoding to instantly bust the cache when a flag is toggled?