· 5 min read
Feature flags and real rollback
A feature flag system with a bounded kill-switch is one table, one function, one circuit breaker. That's what rollback looks like for small teams.
By Marten
The feature flag system I use in my own projects is a database table, a lookup function with a 30-second cache, and a circuit breaker that defaults the flag to off if the lookup fails. That is the whole system. It is under 100 lines of code.
Most “feature flag” writing is about A/B testing frameworks, percentage rollouts, multivariate experiments, remote config, analytics integration. Those are real things that some teams genuinely need. For the use case of “I want to ship this code to production but not have it do anything user-visible yet, and be able to turn it off instantly if it breaks,” none of that is required.
Here is the small version.
The table
CREATE TABLE feature_flags (
key VARCHAR(100) PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
notes TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
One row per flag. enabled is either true or false — no percentages, no user targeting, no environment split. A flag is on or off globally. If I need per-environment, I run a separate database.
INSERT INTO feature_flags (key, enabled, notes) VALUES
('new_checkout_flow', FALSE, 'Added 2026-04-10, turn on after migration verified'),
('expensive_report', FALSE, 'Rate-limited, off until we add caching');
The notes column is the part most implementations skip. It is the most important column. A flag without a note is a flag nobody will remember the purpose of in three months.
The function
function flag(string $key, bool $default = false): bool
{
static $cache = [];
static $cacheExpiresAt = 0;
if (time() > $cacheExpiresAt) {
try {
$rows = DB::query('SELECT key, enabled FROM feature_flags');
$cache = [];
foreach ($rows as $row) {
$cache[$row['key']] = (bool) $row['enabled'];
}
$cacheExpiresAt = time() + 30;
} catch (\Throwable $e) {
// Circuit breaker: DB is down, serve default and try again later
error_log('flag() lookup failed: ' . $e->getMessage());
$cacheExpiresAt = time() + 5;
}
}
return $cache[$key] ?? $default;
}
Usage:
if (flag('new_checkout_flow')) {
return $this->newCheckoutFlow($request);
}
return $this->oldCheckoutFlow($request);
Three properties worth calling out.
The cache is process-local and 30 seconds. Turning a flag on takes up to 30 seconds to propagate to every process. That is acceptable for this use case. It also means the database is hit at most twice per process per minute.
The default is false. If the key does not exist in the table, the flag is off. This is deliberate. It means “add code behind a flag, then decide whether to turn it on” is the natural workflow, and “forgetting to create the flag row” fails closed.
The circuit breaker catches the case where the database is down. Without the try/catch, a DB outage turns every feature flag check into an exception, which takes down the app. With it, a DB outage turns every flag check into a fast no-op that returns the default. Features behind flags stay off. The site keeps working.
What this replaces
If your team does not use feature flags, the alternative is one of: long-lived feature branches, multi-commit releases, or deploying code that is user-visible as soon as it ships. All three have downsides I have been bitten by.
Long-lived branches diverge from main and become merge nightmares. Multi-commit releases are hard to partial-rollback — you cannot undo the third commit without undoing the fourth. Shipping user-visible changes immediately means the deploy and the feature launch are the same event, and those have different risk profiles that benefit from being separate.
A flag decouples them. The deploy ships the code with the flag off. The flag turn-on is a separate, reversible action. If the turn-on reveals a bug, the flag turn-off is equally instant.
What a flag is not
Specifically not: a rollback mechanism for breaking changes outside the flag.
If your deploy also includes a database migration that dropped a column, the flag does nothing for you. The migration ran. The schema is changed. Turning the flag off does not restore the column. Code that used the column is still broken.
Feature flags gate the behaviour that is new. They do not gate the migration, the library upgrade, or the refactor that went in at the same time. If any of those break, the flag is irrelevant.
This is why I separate “ship code behind a flag” from “ship a new schema.” Two different deploys, ordered in time. The schema change ships first, with the old code still working against it. Then the flag goes on. If the flag-gated code breaks, the flag goes off — the schema stays as it was.
The kill-switch loop
For any flag that is on in production, I set a reminder in my calendar two weeks out to either delete the flag or write in the notes why it is still around. Flags that are permanently on are not flags — they are conditionals with a database round-trip. If the feature is permanent, remove the flag.
I have, more than once, removed a flag and had a surprise — either someone had planned to turn it off and forgotten to tell me, or the code path behind the flag had drifted from the primary path in a way that I had not noticed. The flag existed because the two paths still meaningfully differed.
That is a signal. Delete the flag anyway, but understand what it was protecting you from first.
When I actually turn on a new feature
Three steps, in order.
Deploy the code with the flag off. Watch for deploy-time errors. If anything broke at this step, the bug is in the un-flagged parts of the release.
Turn the flag on for a fraction of users, if the flag supports it. (My small version does not — it is on/off globally. For anything user-facing where partial rollout matters, I use a different system.) If my small version is all I have, I turn the flag on and watch the error rate.
Monitor the thing that could break, specifically. Not “monitor.” Have a specific metric in mind, a specific log line, a specific customer flow. Refresh it. If it moves in a bad direction, flag goes off.
Five minutes of watching beats a week of instrumentation done after the fact.
The small version handles 90% of what I have wanted from feature flags in the last five years. Anything more sophisticated comes at a real complexity cost, and most teams do not need to pay it.