LaunchFlag
All posts

Dark launching: ship the code, keep it off, flip it when you are ready

Dark launching means deploying with the feature off, then releasing it without another deploy. How it differs from canary and blue-green, and the case that takes your feature down.

Explainers · · 7 min read · Adarsh Mishra

What dark launching is

#

Dark launching is deploying code to production with the feature switched off, then turning it on separately, later, without another deploy.

The code ships to every server the moment you merge. The branch that runs it sits behind a flag that is off, so nobody reaches it. Enabling the feature is a configuration change that takes seconds. Disabling it again is the same change in reverse.

What that buys you is that the deploy and the release stop being the same event. Deploys become boring, because they change nothing a user can see. Releases become reversible, because undoing one is a switch rather than a revert, a build, a pipeline and a redeploy at two in the morning.

Dark launch vs canary release vs blue-green

#

These three get used interchangeably and they are not the same thing.

What it controlsGranularity
Dark launchThe state code ships in: off, for everyone, until you decideOne feature
Canary releaseWho sees a change and in what order, a slice at a timeOne feature, some users
Blue-greenWhich of two full environments takes trafficThe whole deployment

Dark launching and canary releases compose. You dark launch the code, then release it as a canary by moving the same flag from 0 percent to 5 to 25 to 100.

Blue-green works on a different axis, at the level of the whole deployment. It cannot turn off one bad feature without rolling back everything that shipped alongside it, which is exactly the situation a flag exists to avoid.

How to do it

#

Wrap the new path, keep the old one, merge.

import { flag } from "launchflag";

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

The flag is off, so production runs oldCheckout exactly as before and the deploy changes nothing visible. When you are ready, turn it on. If it goes wrong, turn it off.

The part that is easy to get wrong is the old path. It has to stay until the rollout is finished, because it is what the switch falls back to. A dark launch with the old path already deleted is not reversible, and reversibility was the entire reason for doing it.

New code: born off, with no dashboard step

#

For genuinely new code, off is the default you want, and it should cost nothing to get. A LaunchFlag flag creates itself the first time the code evaluates it, in the off state. You write the if, you merge, and the flag appears switched off with the old path still serving.

That ordering matters more than it sounds. If creating the flag is a manual step that must happen before the deploy, then eventually someone deploys first, and the behaviour in that window is whatever your fallback says. Letting the code create the flag removes the window.

Existing code: the case that takes your feature down

#

The dangerous case is the opposite one. If a new key is always born off, then wrapping a feature that is already live switches it off on whichever host evaluates the key first. You did not dark launch anything. You took a working feature down.

So say what the flag should be born as:

// The third argument is both the answer when we are unreachable
// AND the state the flag is created in, the first time we see the key.
if (await flag("checkout_v2", user.id, true)) return newCheckout(cart);
return oldCheckout(cart);

Passing true keeps the feature on through the deploy and leaves you a switch to turn it off later, which is the actual goal when you wrap live code.

This applies only to a key that has never been seen. If the flag is already on your dashboard, a fallback cannot re-enable something a person switched off. That is deliberate: a kill switch a deploy can undo is not a kill switch.

Why renaming a flag is not a safe refactor

#

That last rule has a consequence worth its own warning, because it has caught real teams. The birth state applies only to a key nobody has evaluated before, so renaming a flag is not a rename.

  • Renaming feature_x to hide_feature_x does not move anything. It creates a second flag, born off, while the first one keeps existing.
  • Renaming into a key that already exists and is off means the fallback is ignored, and your live feature goes dark immediately.
  • Archiving the old flag and re-registering the name does not help. An archived row still exists, and is still skipped.

Treat a flag key as permanent once it has been evaluated. If you genuinely have to change the name, ship the new key alongside the old one, verify it, then remove the old one.

Then delete it

#

Every flag is temporary, and the last step is the one everyone skips. A flag whose rollout has finished is dead code with a switch attached: two branches where one is unreachable, plus a control that can still be flipped by accident.

Once a feature is on for everyone and staying on, delete the flag, delete the old path, delete the check. LaunchFlag looks for these and tells you which flags are settled, meaning on for everyone in every environment and unchanged long enough that the rollout is clearly over, and hands your coding agent the exact edit to make.

That is the part of the lifecycle most tools leave to memory, and memory is why codebases end up full of flags nobody dares remove.

When not to dark launch

#

It is not free. For as long as the flag exists, two code paths are live in production and both have to work, which doubles what you are reasoning about and what you should be testing. That is a good trade for a risky change and a bad one for a typo fix.

  • Skip it when the change is small and obviously safe.
  • Skip it when the old path cannot be kept for real reasons, such as an irreversible database migration.
  • Skip it when the feature cannot be partially on. What you want then is a scheduled cutover, not a flag.
  • Never dark launch a change you have not tested with the flag ON. A switch you have never flipped is not a safety mechanism, it is an untested code path with a button.

If you want the switch without the setup, the docs are one command and one if, and the free plan is one person and one project, forever.

FAQ

#

What is dark launching?

#

Shipping code to production in an off state, then enabling it separately without deploying again. The code is present on every server from the moment you merge, but the branch that runs it is guarded by a flag that is off, so no user reaches it. Turning the feature on is a configuration change that takes seconds, and turning it off again is the same change in reverse. The point is to separate the act of deploying from the act of releasing, so that a bad release does not require a code change to undo.

What is the difference between dark launching and a canary release?

#

Dark launching is about the state the code ships in: off, for everybody, until you decide otherwise. A canary release is about who sees a change and in what order: a small slice of traffic first, then more if the metrics hold. They compose rather than compete. You dark launch the code, then release it as a canary by moving the flag from 0 percent to 5 to 25 to 100. Blue-green is a third thing again: two full environments and a traffic switch between them, which moves everything at once rather than one feature at a time.

How do I dark launch a feature?

#

Wrap the new path in a flag that is off, keep the old path in place, and merge. In LaunchFlag that is one if statement, and the flag creates itself in the off state the first time the code runs, so there is no dashboard step before you ship. The critical part is that the old path stays in the codebase until the rollout finishes, because it is what the switch falls back to. A dark launch with the old path already deleted is not reversible, and reversibility was the whole reason to do it.

What happens if I wrap code that is already live?

#

It goes dark, which is almost never what you want. A new flag key is created in the off state on first sight, so wrapping a feature that is currently serving users switches it off on whichever host evaluates the key first. To avoid that, pass true as the fallback: in LaunchFlag that third argument to flag() is both the answer when the service is unreachable and the state the flag is born in, so the feature stays on through the deploy. This applies only to a key that has never been seen before.

Should I delete the flag afterwards?

#

Yes, and this is the step everyone skips. A flag whose rollout finished is dead code with a switch attached: two branches in your codebase where one is now unreachable, and a switch that can still be flipped by mistake. Once a feature is on for everyone and staying on, delete the flag, delete the old path, and delete the check. If you leave them, you accumulate flags nobody remembers, and the risk moves from the feature to the flag itself.