Feature Flags for Small Teams: Ship Code Without Shipping Features
Feature flags separate deploying code from releasing it: merge unfinished work safely, try features yourself first, roll out gradually, and switch things off without a redeploy. A simple implementation, when to use a service, and how to stop flags becoming clutter.
Normally, deploying code and releasing a feature are the same event: merge, deploy, and everyone has it. That coupling causes a lot of pain. Big features sit on long-lived branches that drift and conflict. Releases become risky all-or-nothing moments. And when something goes wrong, the only way to turn it off is another deploy.
Feature flags break that coupling. The code ships; a switch decides who sees it.
The idea
A feature flag is a condition around new behaviour:
if (await flags.isEnabled('new-checkout', { userId: user.id })) {
return renderNewCheckout()
}
return renderOldCheckout()
The flag's value lives outside the code — in a database table, a config file, an environment variable, or a flag service — so you can change it without deploying.
What flags make possible
Merge unfinished work. Keep branches short-lived: merge small pieces of a big feature to main behind a flag that's off. No month-long branch, no giant merge at the end. This pairs especially well with AI agents, which produce lots of small PRs. (Running Claude Code Agents in Parallel With Git Worktrees.)
Try it yourself in production. Turn the flag on for your own account first. Production data, production infrastructure, zero exposure to customers.
Gradual rollout. 5% of users, then 25%, then everyone — watching error rates at each step. (How to Know When Your App Is Down.)
Instant kill switch. Something goes wrong at 11pm: flip the flag off, and it's gone in seconds. Fix it tomorrow. A rollback without a deploy.
Beta programmes and plans. Turn features on for specific customers, or tie them to a subscription tier.
Kinds of flags
It helps to name what a flag is for, because it determines how long it should live:
| Type | Purpose | Lifespan |
|---|---|---|
| Release | Hide unfinished or rolling-out work | Days to weeks — then remove |
| Kill switch / ops | Turn off a risky or expensive path under load | Long-lived, but few |
| Experiment | A/B test variants | Duration of the experiment |
| Permission / entitlement | Plan tiers, beta access | Long-lived — really configuration |
Most flag mess comes from release flags that were never removed.
A simple implementation
You don't need a service to start. A table and a small function cover most small-team needs:
CREATE TABLE feature_flags (
key text PRIMARY KEY,
enabled boolean NOT NULL DEFAULT false,
rollout_pct int NOT NULL DEFAULT 0 CHECK (rollout_pct BETWEEN 0 AND 100),
allow_users bigint[] NOT NULL DEFAULT '{}',
updated_at timestamptz NOT NULL DEFAULT now()
);
import { createHash } from 'node:crypto'
function bucket(key: string, userId: number): number {
const h = createHash('sha256').update(`${key}:${userId}`).digest()
return h.readUInt32BE(0) % 100
}
export async function isEnabled(key: string, ctx: { userId?: number }) {
const flag = await getFlag(key) // cache this for ~30s
if (!flag || !flag.enabled) return false
if (ctx.userId && flag.allow_users.includes(ctx.userId)) return true
if (!ctx.userId) return flag.rollout_pct === 100
return bucket(key, ctx.userId) < flag.rollout_pct
}
Two details matter:
- Stable bucketing. Hashing
flagKey + userIdmeans each user consistently gets the same answer as the percentage rises — nobody flickers between old and new. Including the flag key means different flags roll out to different users. - Cache the lookup briefly (in memory, for tens of seconds). You don't want a database query per flag check per request. The cache interval is how long a kill switch takes to apply.
When to use a service
Hosted flag services (LaunchDarkly, and several open-source or self-hostable options like Unleash, Flagsmith, and GrowthBook) add: a UI for non-developers, targeting rules, audit logs of who changed what, experiment analysis, and SDKs that evaluate flags locally with streaming updates.
Worth it when several people manage flags, you run real experiments, or you need an audit trail. Overkill for a solo developer with five flags.
Server-side vs client-side flags
- Evaluate on the server whenever possible. It's authoritative and keeps unreleased logic private.
- Client-side flags (evaluated in the browser) are fine for UI variations, but remember the browser can see them — and any code shipped behind a client-side flag is already downloaded. A flag hiding a button is not a permission check; the backend must still enforce access. (Authentication vs Authorization.)
Flags and the database
Flags work beautifully for code. They're trickier for schema changes, because you can't "turn off" a migration. Combine them with the expand/contract pattern: expand the schema first (additive, safe for both paths), release behind the flag, and only contract once the flag is permanently on and removed. (Zero-Downtime Deploys for a Small App.)
Keeping flags from rotting
Every flag is a branch in your code, doubling the paths through it. Twenty stale flags make a codebase hard to reason about — for humans and AI agents alike. Discipline:
- Give every release flag an owner and an expiry date when it's created.
- Remove the flag in the same sprint the rollout completes: delete the check, the old path, and the table row.
- Test both paths while the flag exists.
- List stale flags regularly. A query for flags at 100% (or 0%) for more than a few weeks is your cleanup list.
Flag removal is excellent agent work:
The
new-checkoutflag has been at 100% for three weeks. Remove it: delete the flag check, the old checkout code path and its tests, and write a migration deleting the flag row. Run the tests.
The checklist
- Flags evaluated on the server by default
- Stable, per-flag user bucketing for percentage rollouts
- Short-lived cache for flag lookups
- Every release flag has an owner and an expiry
- Both paths tested while the flag exists
- Flags removed promptly after full rollout
- Flags never used as a substitute for permission checks
EasySpawn gives each branch a live URL on Pro and runs Claude Code against your real app and database — pair it with flags to merge small changes continuously and release on your schedule. See how it works or join the waitlist.
Related: Preview Environments for Every Branch · Dev, Staging, and Production Explained · Analytics for Beginners
Keep reading
Reverse Proxies Explained: Nginx, Caddy, and Traefik in Front of Your App
A reverse proxy sits between the internet and your app, handling TLS, routing, compression, and more. What reverse proxies do, how Nginx, Caddy, and Traefik differ, forwarded headers and trusting the real client IP, WebSockets and streaming, timeouts and body limits, and common 502/504 causes.
Designing a REST API That Won't Embarrass You Later
APIs are hard to change once clients depend on them. The conventions that keep a REST API predictable — resource naming, methods, status codes, errors, pagination, validation, idempotency, and versioning — with the specific mistakes AI-generated APIs tend to make.