I wrote an article telling small teams they do not need a feature flag platform. They need a handful of kill switches on the things that are expensive to undo, kept in one server-side place, each with a safe default and an owner.
Then I did the obvious thing and grepped my own products to see whether I follow my own advice. Partly, it turns out. This is what I found, and what I think the gap actually teaches.
The one question behind every flag
Before anything ships, I ask: if this is wrong, how long until it stops hurting?
- A web app on a modern host: minutes. Redeploy the previous build.
- A mobile app: a store review, plus however long people take to update, which for some users is never.
- A voice agent answering a clinic's phones: every call that comes in while you are still working out what broke.
A feature flag is just a value the running software reads at runtime, stored somewhere you can change without shipping code. Its whole job on a small team is to make that answer short in the places where rollback is slow.
I sort flags into four jobs, because mixing them is how you end up with a mess:
- Kill switches. Turn something off fast when it misbehaves. Long-lived and the most valuable.
- Release flags. Ship code dark, turn it on later, then delete the flag.
- Operational settings. Limits, thresholds, which model or provider to call.
- Experiments. A/B tests. Mostly a statistics problem, and most early products lack the traffic for it.
On a team of two to nine people I build the first two from day one and push back on the fourth.
What my own code actually has
I looked at two of my products: Upwork Scout, a job-alert SaaS on Next.js and Firestore, and VoiceDash, a white-label client portal for Retell voice agents on Next.js and Prisma.
Operational settings: present, but behind a redeploy
Upwork Scout's scan cron reads its limits from the environment, each with a fallback:
const PER_RUN_LIMIT = Number(process.env.SCAN_ITEMS_PER_CATEGORY || 5); const DAILY_BUDGET = Number(process.env.GLOBAL_DAILY_FETCH_BUDGET || 150); const JOB_MAX_AGE_H = Number(process.env.JOB_MAX_AGE_HOURS || 24); The model is the same pattern: process.env.AI_MODEL || "claude-haiku-4-5".
This is the right category of thing to make configurable, and the defaults are sensible. But environment variables are build-and-deploy configuration. Changing one on most hosts means a redeploy, so the "how long until it stops hurting" answer for these is "however long a deploy takes", not seconds. That is fine for a budget I tune once a month. It is not a kill switch.
One detail I only noticed while writing this: Number(undefined || 5) gives you a safe default when the variable is missing, but Number("abc") gives you NaN, and nothing checks for it. I traced it through by hand. A NaN budget makes remaining <= 0 false, then slice(0, Math.max(1, NaN)) returns an empty list, so the cron scans nothing. It fails closed, which is the safe direction, but it does so silently: the only symptom would be a day with no alerts. That is luck, not design. If a value controls spend, validate it at startup and log loudly when it is garbage.
The accidental kill switch
The interesting part is that the real off switch in Upwork Scout is not an environment variable at all. The daily budget is enforced against a document in Firestore:
const spend = spendSnap.exists && spendSnap.get("date") === today ? (spendSnap.get("fetched") as number) : 0; const remaining = DAILY_BUDGET - spend; if (remaining <= 0) activeCats = []; So if the scraper ever started returning junk or burning money, writing a large fetched number into today's spend document would stop every fetch on the next run, no deploy needed. It is a kill switch that exists by accident, with a safe failure direction (when the budget is gone, nothing is fetched), living in data rather than code.
That is exactly the property I tell clients to design for on purpose. It works here because the check was written as "read a number from the database and stop if it is spent", not because anyone planned an emergency off button. Nobody would find it at 11pm either, which is the other half of the rule: a switch nobody knows about is barely a switch.
The per-user version is deliberate and visible: every user has an active boolean, the cron only loads where("active", "==", true), and the dashboard shows a toggle that says either "Alerts ON" or "Alerts PAUSED". That one has a clear owner (the user), a clear default, and a clear effect. It is the best flag in the codebase and nobody would call it one.
Outward integrations: switched by emptiness
In VoiceDash, two things happen after a Retell call webhook lands that reach outside the request: an AI analysis call (which costs money) and forwarding the payload to any URLs the agency configured.
const aiConfig = agent.aiAnalysisConfig as any; if (aiConfig?.summary || aiConfig?.evaluation) { fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/conversations/analyze`, { /* ... */ }) .catch(() => {}); } const forwardUrls: string[] = agent.forwardingWebhookUrls || []; for (const fUrl of forwardUrls) { fetch(fUrl, { method: "POST", /* ... */ }).catch(() => {}); } Both are per-agent and both are off by default (no config, empty array). Turning forwarding off means clearing the URL in the agent settings tab. That is a real per-tenant kill switch for an outward integration, which is what I would want.
What it lacks is the rest of the checklist. There is no record of who cleared it or when, and "off" and "never configured" look identical in the data. When a client asks why their CRM stopped getting calls on Tuesday, an empty array cannot tell you whether someone turned it off or it was never on.
Flags that exist and do nothing
This was the finding I did not expect. The VoiceDash workspace model has two compliance-mode booleans, one defaulting to true, and there is an Automation model with an ACTIVE/INACTIVE status. A search of the application code finds nothing that reads any of them.
They are not harmful today, since there is no UI that flips them. But they are the worst kind of flag: a stored value that looks like it controls behaviour and does not. The day someone adds a toggle for one of them, it will appear to work. Schema-first design makes this easy to do by accident: you model what the product will need, ship the columns, and the reading side never lands.
My fix for that is cheap: every flag gets a description that names the code path that reads it, and a flag with no reader is a bug in review, the same as an unused import.
The rules, revised against reality
Here is the list I give clients, with what my own code taught me added in.
- One place, read on the server. Kill switches belong in data the backend reads at request time, not environment variables. My accidental Firestore switch works for precisely this reason.
- Every flag has a safe default, written next to it. New and risky features fail closed, fallbacks fail open. And validate the value, because
NaNis not a default. - Scope by version and platform on anything shipped through an app store, so you can turn a feature off for iOS builds below a version without punishing everyone.
- Name who can flip it. For client voice agents that includes someone at the business, with a short runbook, because I will not always be the first person awake.
- Log every change. "Off" and "never on" must be distinguishable after the fact.
- No flag without a reader. If nothing in the code reads it, delete it or finish it.
- Release flags get a removal date when they are created, and the feature is not done until the flag is gone.
The minimum I would build on day one now
- A flags table the backend serves: name, value, safe default, owner, removal date, and the file or function that reads it.
- The app sending its version and platform on every request.
- A kill switch around each outward integration, stored as data, not env.
- An append-only change log for flag flips.
- A one-page note saying who can flip what, and what the product does when each switch is off.
That is a day or two of work. Writing the article took less time than finding out that my own projects only half follow it, and the half they follow mostly happened by accident. I think that is the honest normal state for a small team, and the reason to grep your own code before you trust your own advice.
The original, more client-facing version of this is on my site: feature flags and kill switches for a small team. I have also written here about what webhooks do not guarantee, which is the other half of trusting an outward integration.
What is the oldest flag in your codebase that nobody can explain anymore?


