Build Your Own Feature Flags Before You Pay Someone Else To
Every team I've worked on eventually hits the same fork in the road: someone wants to ship a risky change behind a flag, and someone else says "let's just add LaunchDarkly." Sometimes that's the right call on day one. But I've also watched a four-person startup sign a substantial annual contract for a feature flagging tool to manage exactly six flags, three of which were permanent kill switches nobody ever toggled. That's not a flagging problem, that's a spreadsheet with extra steps.
Feature flags, at their core, are just a lookup: given a flag key and a context (user, org, request), return a boolean or variant. You can build a genuinely solid version of this in an afternoon, and I want to walk through exactly how, including the parts people usually get wrong — percentage rollouts, consistent bucketing, and caching so you're not hitting your database on every request.
The data model
Start simple. A flags table and, if you want per-user overrides, an overrides table.
CREATE TABLE feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT UNIQUE NOT NULL,
description TEXT,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INTEGER NOT NULL DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE feature_flag_overrides (
flag_id UUID NOT NULL REFERENCES feature_flags(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
enabled BOOLEAN NOT NULL,
PRIMARY KEY (flag_id, user_id)
);
That's genuinely most of what you need. enabled is your master kill switch — off means off for everyone, no exceptions, no math. rollout_percentage is your gradual rollout dial. Overrides let you force a flag on or off for specific users, which you'll want almost immediately for internal QA and for the inevitable "can you just turn this on for this one customer" support request.
Consistent bucketing — the part everyone gets wrong first try
The naive approach to a percentage rollout is Math.random() < percentage / 100, evaluated on every request. Don't do this. It means the same user gets a different answer every time they load the page, which is both a confusing user experience and impossible to debug ("it worked when I refreshed"). What you want is a deterministic hash of the user ID and the flag key, so the same user always lands in the same bucket for that flag, but different flags don't correlate with each other (otherwise everyone in your first 10% rollout bucket ends up in every 10% rollout bucket, which skews your sample).
import { createHash } from 'crypto';
function isInRollout(userId: string, flagKey: string, percentage: number): boolean {
if (percentage >= 100) return true;
if (percentage <= 0) return false;
const hash = createHash('sha256').update(${flagKey}:${userId}).digest('hex');
const bucket = parseInt(hash.slice(0, 8), 16) % 100;
return bucket < percentage;
}
This is essentially what every real flagging service does under the hood — LaunchDarkly's docs describe the same salted-hash approach. Salting with the flag key means a user who's in the 10% bucket for new-checkout-flow isn't automatically in the 10% bucket for new-dashboard-nav. That independence matters more than people expect the first time they're running two rollouts simultaneously and someone asks "wait, is this the same 10% of users for both?"
The evaluation service
Wrap all of this behind a single function so your application code never touches percentages or hashes directly.
import { LRUCache } from 'lru-cache';
type FlagContext = { userId: string };
const cache = new LRUCache({ max: 500, ttl: 30_000 });
async function getFlag(key: string): Promise {
const cached = cache.get(key);
if (cached) return cached;
const row = await db.query('SELECT * FROM feature_flags WHERE key = $1', [key]);
if (row) cache.set(key, row);
return row;
}
export async function isEnabled(key: string, ctx: FlagContext): Promise {
const flag = await getFlag(key);
if (!flag || !flag.enabled) return false;
const override = await db.query(
'SELECT enabled FROM feature_flag_overrides WHERE flag_id = $1 AND user_id = $2',
[flag.id, ctx.userId]
);
if (override) return override.enabled;
return isInRollout(ctx.userId, key, flag.rollout_percentage);
}
The 30-second in-memory cache matters more than it looks like it should. Without it, every flag check is a round trip to Postgres, and if you're checking flags in a hot path (middleware, a frequently rendered component), that adds up fast. A 30-second TTL means a flag toggle takes up to 30 seconds to propagate, which for 95% of use cases is completely fine — you're not building a real-time kill switch for a security incident with this, and if you are, that's precisely the use case where a dedicated service earns its keep.
Wiring it into React
For the frontend, you want flags resolved server-side and passed down, not re-fetched client-side per component — that avoids a flash of default content and keeps your bucketing logic in one trusted place. A simple context provider does the job:
const FlagContext = createContext>({});
export function FlagProvider({ flags, children }: { flags: Record; children: React.ReactNode }) {
return {children} ;
}
export function useFlag(key: string): boolean {
const flags = useContext(FlagContext);
return flags[key] ?? false;
}
In a Next.js app, you'd resolve the flags you care about in a server component or in middleware, then hydrate FlagProvider with the result:
// middleware.ts
export async function middleware(req: NextRequest) {
const userId = getUserIdFromSession(req);
const showNewCheckout = await isEnabled('new-checkout-flow', { userId });
const res = NextResponse.next();
res.headers.set('x-flag-new-checkout', String(showNewCheckout));
return res;
}
Then in the component:
function CheckoutPage() {
const useNewFlow = useFlag('new-checkout-flow');
return useNewFlow ? : ;
}
One thing I learned the hard way: resist the urge to let flags leak into every layer of the app. I once inherited a codebase where a single flag was checked in six different places — the API route, two React components, a cron job, and a GraphQL resolver — with slightly different logic in each because someone had copy-pasted the percentage check instead of calling a shared function. When we finally killed that flag, it took two days to find every reference. Centralize the check, even if it means an extra function call.
What this setup won't give you
This is a genuinely solid system for boolean and percentage-based flags with per-user overrides. It is not a replacement for a real flagging service once you need any of the following: targeting by arbitrary attributes (plan tier, geography, account age) rather than just user ID, multivariate flags with more than two variants, a real audit log of who toggled what and when, scheduled flag changes, integration with experimentation/analytics for automatic statistical readouts, or a UI that non-engineers can use without you writing an admin panel yourself.
That last one is usually the actual trigger. The code above works great until your product manager asks to toggle a rollout percentage without pinging an engineer, and you realize you now need to build a whole internal admin UI, complete with auth, just to edit a database row. That's roughly the point — in my experience, somewhere between 15 and 30 active flags with more than one non-engineer needing to touch them — where the calculus flips and paying for LaunchDarkly or a similar service (Flagsmith and Unleash are solid open-source alternatives if you want to self-host rather than pay per seat) actually saves you engineering time versus maintaining a bespoke admin tool.
The mistake is reaching for that tool on day one, before you know your actual usage pattern, and paying both in dollars and in an extra external dependency for a problem a 60-line service and a Postgres table already solved.
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.