All posts
5 min read

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.

deploymentdeveloper experiencearchitectureintermediate

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 + userId means 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-checkout flag 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