LaunchFlag
All posts

We unshipped a broken checkout from a phone in 9 seconds

An agent wrote the checkout, I merged it, a customer found the bug. What a kill switch is, why the old path has to stay, and the three lines that make it work.

· 6 min read · Adarsh Mishra

A kill switch is a flag whose only job is to turn a shipped feature off without a deploy. Here is what it cost us not to have one.

The Tuesday

#

I shipped a checkout rewrite on a Tuesday evening. I did not write most of it. An agent wrote it, I read the diff, the tests passed, the preview looked right, and I merged at 18:40, which is the kind of thing you do when you are the only person who can do it.

At 19:12 a customer sent two words in Slack and a screenshot. The words were "checkout broken". The screenshot was the confirm button spinning forever on mobile Safari. I was on a train with a phone and no laptop.

Here is the part nobody puts in the postmortem: I knew what was wrong within about a minute. The fix was never the hard part. The hard part was that the broken path was live and would stay live for as long as it took me to do something about it, and every option I had was measured in minutes.

  • Revert and redeploy. A git revert, a push, a build. Four minutes on a good day, and it needs a laptop and a clean head, and I had neither.
  • Roll back the deployment. Instant, and it takes back the two unrelated fixes that shipped in the same commit. Rolling back a deploy is a blunt instrument because a deploy is a bundle of unrelated decisions.
  • Patch forward from a phone. No. Writing production code on a train is how a small incident becomes a large one.

All three answers treat the deploy as the unit of control. That is the actual bug. The deploy was fine. One branch inside it was not.

What a kill switch actually is

#

A kill switch is not a feature flag with a dramatic name. A feature flag is any boolean you can read at runtime. A kill switch is a flag whose off state is a path you already trust, which is a design decision you make before you ship, not a setting you turn on afterwards.

Four things have to be true or you do not have one.

  • The old path still exists and still runs. If you delete oldCheckout the day you ship newCheckout, you do not have a kill switch, you have a hope. Delete it a week later when the new path has survived real traffic.
  • Off is the safe side. The switch has to fall towards the boring behaviour, not towards a blank screen or a half-migrated write.
  • You can reach it from a phone. Incidents do not wait for you to be at a desk. If flipping it needs a terminal, an env var and a redeploy, it is a config change, and a config change is a deploy with extra steps.
  • Flipping it leaves a record. Who, what, when, which environment. Not for compliance. For the version of you at 07:00 the next morning trying to reconstruct what happened.

This matters more now than it did three years ago, and for an unglamorous reason: more of the code going to production was not typed by the person who approved it. I read the diffs. I still do not have the same model of that code in my head that I would have if I had written it line by line, and pretending otherwise is how you end up on a train at 19:12. A kill switch is what you put around code you believe in but have not earned the right to trust yet.

Nine seconds

#

I opened LaunchFlag on my phone, tapped the project, and tapped the switch next to checkout_v2. Off. From unlocking the phone to the switch reading off was about nine seconds, most of it Face ID and a slow train connection.

The next request served the old checkout. The SDK holds one config document for five seconds by default, so the worst case for a running server is a five second wait, and self-hosted installs behind Cloudflare purge that cache on every change so it is closer to instant. Nobody deployed anything. The commit I had merged twenty minutes earlier was still in production, unchanged, doing nothing.

I fixed it the next morning with a laptop and coffee, tested it in dev, promoted dev to staging and staging to prod, and turned it back on at 10 percent. That is the entire story, and the only reason it is short is that the old checkout was still in the tree and still reachable.

The three lines

#

The integration is one import and one if. There is no dashboard step first, no flag to create, no YAML.

Your code

import { flag } from "@launchflag/sdk";

if (await flag("checkout_v2", user.id)) return newCheckout(cart);
return oldCheckout(cart);

The first time that code runs, the flag registers itself, off in every environment, and appears on the dashboard. So the sequence is: write the branch, ship it, and the switch is waiting for you. You never have to remember to create the flag before the code that reads it exists, which is the step everybody forgets on a Friday.

The second argument is whoever you target by, usually a user id or an email. Code with no user, like a cron job or a webhook, passes nothing and only sees flags aimed at everyone. In React the same thing is a component with a fallback, and both are server side: LAUNCHFLAG_KEY is a secret and belongs in server components, route handlers, server actions or middleware, never in the browser.

Fail closed, not fail open

#

Every flag service eventually has a bad minute, and the only question that matters is what your app does during it. There are two honest answers and you have to pick one per flag.

Fail closed means that when nothing is known, the feature is off and the old path runs. That is right for money, auth, deletion and anything with an external side effect, and it is the default for every flag LaunchFlag registers for you. Fail open means the feature stays on, which is right for cosmetics: a new nav that quietly disappears because a config fetch timed out is a worse outcome than a new nav.

In practice there are three separate protections and it is worth knowing which one saves you. A slow API never blocks a render, because requests abort at 800 ms. A down API never flips a flag, because a failed fetch keeps serving the last config it successfully fetched and retries on the next window. And a cold process that has never reached us at all serves your declared defaults, which is the lever you actually control.

Explicit config

import { createLaunchFlag } from "@launchflag/sdk";

const lf = createLaunchFlag({
  key: process.env.LAUNCHFLAG_KEY!,
  ttlMs: 5000,        // how long one fetched config is reused
  timeoutMs: 800,     // abort a slow request instead of blocking a render
  defaults: { new_nav: true, checkout_v2: false },
});

flag() and variant() never throw. If the network is gone and no default is set, you get false, which is the same thing as the feature not shipping yet.

What LaunchFlag does not do

#

LaunchFlag is not an analytics product and is not going to become one. There is no session recording, no funnel, no significance test, no metric attached to a flag. If you run a multivariate flag, we serve the arm and record nothing else about it: measure it wherever you already measure things.

The only number we keep is how many times each flag was evaluated per day, and we keep it for two reasons. It tells you which flags are stale, meaning nobody has touched them in 30 days but production is still asking about them, which is how you find the ones to delete. And it is what your bill is based on.

That is a deliberate trade. A tool that also owns your experiment analytics has an interest in you keeping flags around. A tool that only owns the switch wants you to delete them, because the good end state for any flag is that it stops existing.

One line

#

One command writes your dev key into .env.local, downloads the agent skill and configures the MCP server so Claude Code and Cursor can create and flip flags themselves. The install guide has the rest: variants, environments, the raw API, and what happens when we are unreachable.

Terminal

$ npx launchflag init

Then write the if, ship it off, and go to dinner. The switch is on your phone.

FAQ

#

What is a kill switch in software?

#

A kill switch is a feature flag whose only job is to turn a shipped feature off without a deploy. Its off state is a code path you already trust and that still runs, the flag falls towards that path when anything goes wrong, you can flip it from a phone, and every flip leaves a record of who changed what and when.

Should a feature flag fail open or fail closed?

#

Fail closed means the feature is off when nothing is known, so the old path runs. That is right for money, auth, deletion and anything with an external side effect, and it is the default for every flag LaunchFlag registers. Fail open means the feature stays on, which is right for cosmetic changes, where a nav bar vanishing because a config fetch timed out is worse than the new nav bar.

What does LaunchFlag not do?

#

LaunchFlag is not an analytics or experimentation product. There is no session recording, no funnel, no significance test and no metric attached to a flag. It serves the variant arm and keeps one number, how many times each flag was evaluated per day, which is what finds stale flags and what the bill is based on. Measure results wherever you already measure things.