On this page 4 sections
This is an opinion post, so here is the opinion up front: a team of under fifty engineers, shipping one or two services, does not need a feature flag vendor. It needs a table, about thirty lines of code, and a rule that every flag has an owner and an expiry date.
We ran a hosted flag service for two years. It was fine. It also became the place where 140 flags went to be forgotten, it added a network dependency to our boot sequence, and the bill grew with our monthly active users rather than with anything we used. When we moved off it, the replacement took two days.
The table
Flags live in the same Postgres database as everything else. That means they are backed up, they show up in the audit log, and changing one is an UPDATE with a name attached.
CREATE TABLE feature_flags (
key text PRIMARY KEY,
enabled boolean NOT NULL DEFAULT false,
rollout_pct smallint NOT NULL DEFAULT 0 CHECK (rollout_pct BETWEEN 0 AND 100),
allow_ids text[] NOT NULL DEFAULT '{}',
owner text NOT NULL,
expires_on date NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO feature_flags (key, enabled, rollout_pct, owner, expires_on)
VALUES ('new-invoice-editor', true, 10, 'hana', '2026-09-30');owner and expires_on are NOT NULL on purpose. You cannot create a flag without saying who will delete it and when.
Evaluation is a pure function
Percentage rollouts need to be sticky: a user who sees the new editor at 10% must still see it at 20%. Hashing the flag key together with the user ID gives each user a stable bucket from 0 to 99, and including the flag key means the same 10% of users do not get every experiment.
// flags.ts (createHash comes from node:crypto)
export type Flag = {
key: string;
enabled: boolean;
rolloutPct: number;
allowIds: string[];
};
export function bucket(flagKey: string, subjectId: string): number {
const digest = createHash('sha1').update(`${flagKey}:${subjectId}`).digest();
return digest.readUInt32BE(0) % 100;
}
export function isOn(flag: Flag | undefined, subjectId: string): boolean {
if (!flag || !flag.enabled) return false;
if (flag.allowIds.includes(subjectId)) return true;
return bucket(flag.key, subjectId) < flag.rolloutPct;
}An unknown flag is off. A disabled flag is off, even for allow-listed users, which gives you a kill switch. The modulo bias from 2^32 values into 100 buckets is far too small to matter for rollouts.
Each service loads the whole table into a Map at startup and refreshes it every 15 seconds. If the refresh fails, it keeps the last good copy and logs a warning. Flag checks never touch the network, and a database blip never flips a feature off.
The rule that matters more than the code
Every flag tool eventually fills up with flags nobody remembers. The code above does nothing to prevent that. A CI check does:
- A nightly job lists flags whose
expires_onhas passed and opens an issue assigned to the owner. - A lint rule fails the build if the code references a flag key that is not in the table, or the table holds a flag no code references for 30 days.
- Extending an expiry date is allowed, but it needs a commit with a reason.
When we would buy one
There are good reasons to pay. If product managers or support staff need to toggle flags without asking an engineer, you need a real interface with permissions, and building it well is more than two days. If you run proper experiments, you need exposure logging and statistics you can trust. If you have services in six languages, you want SDKs someone else maintains.
We have none of those needs today. Our flags are a deploy tool for engineers: release dark, ramp to 10%, watch the error rate, ramp to 100%, delete. For that job, a table and a function are easier to reason about than any dashboard, and our count of live flags has gone from 140 to 11.