Feature flags without a platform
You do not need LaunchDarkly to ship behind a flag. A Postgres table, a cached read, and a typed helper get you most of the value.

Every few months a client asks us to wire up a feature flag platform, and most of the time we talk them out of it. Not because the platforms are bad, but because they are answering a question the team has not actually asked yet. If you have three engineers and a handful of flags, you can get the core of feature flagging from a database table and about forty lines of code.
This post is the version we wish someone had handed us years ago: how to do feature flags without a platform, when that is genuinely enough, and the exact moment you should stop and pay for one.
What a flag actually is
Strip away the marketing and a feature flag is a function. Given a flag name and some context (a user, a request, an environment), it returns true or false. Everything else (dashboards, audit logs, percentage rollouts, targeting rules) is convenience built on top of that function.
The trap is reaching for the convenience before you need it. A SaaS flag platform bills per seat or per monthly active user, adds a network round trip or an SDK to your bundle, and becomes one more thing that can page you at 2am. For a small team shipping a few features a quarter, that is a lot of ceremony to answer a yes or no question.
So start with the function and only grow it when reality forces your hand.
The simplest thing that works
Put your flags in the database you already run. A single table, one row per flag:
create table feature_flags (
key text primary key,
enabled boolean not null default false,
description text,
-- 0 to 100, only consulted when enabled is true
rollout_pct smallint not null default 100
check (rollout_pct between 0 and 100),
updated_at timestamptz not null default now()
);Now a typed helper to read it. The important detail is caching: you do not want a database hit on every flag check, and you do not want a flag change to take a full deploy to show up. A short in-memory cache splits the difference.
import { db } from "@/lib/db";
type FlagRow = { enabled: boolean; rollout_pct: number };
const CACHE_TTL_MS = 30_000;
let cache: { rows: Map<string, FlagRow>; expires: number } | null = null;
async function loadFlags(): Promise<Map<string, FlagRow>> {
if (cache && cache.expires > Date.now()) return cache.rows;
const result = await db.query<{ key: string } & FlagRow>(
"select key, enabled, rollout_pct from feature_flags",
);
const rows = new Map(result.rows.map((r) => [r.key, r]));
cache = { rows, expires: Date.now() + CACHE_TTL_MS };
return rows;
}
// Stable hash so the same user always lands in the same bucket.
function bucket(key: string, id: string): number {
let h = 2166136261;
for (const ch of `${key}:${id}`) {
h ^= ch.charCodeAt(0);
h = Math.imul(h, 16777619);
}
return (h >>> 0) % 100;
}
export async function isEnabled(
key: string,
ctx?: { userId?: string },
): Promise<boolean> {
const flag = (await loadFlags()).get(key);
if (!flag || !flag.enabled) return false;
if (flag.rollout_pct >= 100) return true;
if (!ctx?.userId) return false; // no identity, no partial rollout
return bucket(key, ctx.userId) < flag.rollout_pct;
}That is the whole engine. The hash matters more than it looks: because bucket is deterministic, a user who sees the feature at 10% rollout still sees it at 40%. People do not flicker in and out of the feature between requests, which is the bug that makes home-grown flags feel janky.
Using it reads exactly how you would hope:
if (await isEnabled("new-checkout", { userId: session.userId })) {
return <NewCheckout />;
}
return <LegacyCheckout />;The parts people get wrong
The naive version of this works in a demo and bites you in production. Three things separate a toy from something you can trust.
Caching and the staleness window
A per-request database read for every flag adds latency and load you do not need. But an unbounded cache means flipping a flag off during an incident does nothing for an unknown amount of time. Pick a deliberate TTL (30 to 60 seconds is sane) and make sure everyone knows that is your blast-radius window. If you are on serverless where each invocation is cold, the in-memory cache buys you less, so lean on a shared cache like Redis or Vercel Edge Config instead, with the same TTL idea.
Fail closed, and define what "closed" means
When the database read throws, what happens? If isEnabled returns false on error, an outage silently turns off every gated feature, including ones that have been fully live for months. That is usually worse than the original problem. The fix is to treat a flag at 100% rollout as effectively permanent and stop reading it through the flag system once the rollout is done. A flag is a temporary switch, not a permanent config knob. Which leads to the real failure mode.
Flags are debt, so give them an expiry
The actual cost of home-grown flags is not the code, it is the flags you never delete. Every flag is a live if branch, a second code path, and a small tax on every future change to that file. Write the cleanup ticket the same day you add the flag. A flag that has been at 100% for a month should be ripped out, not left as scenery. We keep a description column partly so a quarterly select key, description, rollout_pct from feature_flags order by updated_at reads like a to-do list of branches to delete.
Giving non-engineers a switch
A table is great until a product manager wants to flip a flag at 9pm and you are the only one with a database client. You do not need a UI for this. A tiny internal admin route guarded by your existing auth, the kind of plumbing that falls squarely under API and backend engineering, covers it:
// app/api/admin/flags/route.ts
export async function POST(req: Request) {
const session = await requireAdmin(req); // your existing auth
const { key, enabled, rolloutPct } = await req.json();
await db.query(
`insert into feature_flags (key, enabled, rollout_pct)
values ($1, $2, $3)
on conflict (key) do update
set enabled = $2, rollout_pct = $3, updated_at = now()`,
[key, enabled, rolloutPct ?? 100],
);
return Response.json({ ok: true });
}Drop a five-field form in your admin area against that route and product can toggle without touching code or pinging you. That single endpoint removes most of the day-to-day reason teams cite for buying a platform.
When to actually buy the platform
This approach has a ceiling, and pretending otherwise is how people get burned. Stop rolling your own and pay for a real platform when you hit any of these:
- You need an audit trail: who flipped what, when, and why, for compliance or post-incident reviews.
- You want targeting rules beyond a percentage: by plan tier, by region, by account, with an AND/OR editor non-engineers can use safely.
- You are running experiments and need flags wired to a stats engine that tells you whether the variant actually won.
- You have many services in different languages and want one consistent source of truth with mature SDKs instead of reimplementing
isEnabledfive times. - Flag changes have become risky enough to need approvals, scheduling, and instant kill switches with guaranteed propagation.
None of those are nice-to-haves you grow into casually. Each one is a real signal that the convenience layer is now worth paying for. Until you hit one, a platform is mostly cost and dependency.
The takeaway
Feature flags are a function that returns a boolean. A Postgres table, a cached typed helper, a stable hash for rollouts, and a small admin route give you that function for almost nothing, and they scale further than most teams expect. Add a deliberate cache TTL, decide how you fail, and treat every flag as debt with an expiry date. When you genuinely need audit logs, targeting, or experiments, buy the platform with a clear conscience. Until then, the good kind of lazy is the right call.
If you want a second pair of eyes on where your team sits on that line, that is the sort of thing we are happy to look at.

