Drift: Feature Flags without Feature Flags

Mar 5, 2026

Flagging Without Feature Flags

Every feature flag you ship is a promise you'll clean it up later. You won't.

Six months from now that flag is still there serving true to everyone. One fateful day a butterfly flaps its wings and your application fails to load flags, the fallback value is still false. Surprise revert for everybody!

Flag cleanup is the tax nobody budgets for. You pay it in cognitive load, in dead branches that look alive, in deploy anxiety when someone asks "wait, is that flag still doing anything?" The tooling around feature flags is sophisticated — percentage rollouts, user targeting, kill switches — but the lifecycle always ends the same way. A human has to go in, understand what the flag was doing, verify that one path is dead, rip out the conditional, delete the variations, and hope they didn't miss a spot. It's tedious, error-prone, and it scales with every flag you add.

After seeing this pattern play out countless times over the 6 years I've worked for LaunchDarkly, a crazy idea kept sneaking back into my head: what if the flag never touched your code in the first place?

Not "what if we had better linting rules" or "what if we added a Slack reminder." What if the mechanism was fundamentally different — what if you could run two versions of a module side by side, dispatch between them per-request, and when you're done, just... stop. Delete the old commit reference. Your code never had a conditional. There's nothing to clean up because there was never anything to remove.

This isn't a completely new idea. Erlang and the BEAM VM have done hot code reloading for decades — running old and new versions of a module simultaneously, draining connections from one to the other, with no downtime and no flags. Dark Lang took it further, building a whole platform around versioned handlers where every function is an immutable snapshot and traffic shifts between them. GitHub's Scientist library approached it from a different angle — shadow-testing new code paths against old ones in production, comparing results without affecting users.

This project takes a bit from each. From Erlang, the idea that "two versions running at once" is a runtime concern, not a code concern. From Dark, the conviction that versions should be addressable artifacts, not branches in an if-statement. From Scientist, the principle that you should be able to observe both paths before committing. (We haven't built the shadow testing piece yet. But the architecture supports it.)

The result is drift — a Node.js tool that enables module-level progressive delivery backed by LaunchDarkly. You point it at a module. You commit a new version. You tell LaunchDarkly who should get what. The runtime handles everything else. Your source code never sees an if statement. When the rollout is done, you remove the drift configuration. There is no flag to clean up because there was never a flag in your code.

Drift comes with a simple command line tool for managing releases. In the future, I'd like to deeply integrate it with both CI/CD pipelines and IDEs via an extension.

usage: drift <command> [args]

commands:
  init <releaseId> <filePath>    Initialize a release
  mark <releaseId> [--name X]    Mark current commit as variation
  promote <releaseId> <variation> Promote a variation
  status [releaseId]             Show release status
  remove <releaseId>             Stop drifting a module
  fallback <releaseId> <var>     Set fallback when LD is unavailable
  archive <releaseId>            Archive flag and stop drifting
  import <releaseId> <filePath>  Restore a release from LD flag

Initial Setup

We have a simple express.js application with a route:

In app.js:


import { drift } from 'drift/express';

const app = drift(express(), {
  context: (req) => ({
    kind: 'user',
    key: req.headers['x-user-id'] || 'anonymous',
    plan: req.headers['x-user-plan'] || 'free',
  })
});

app.post('/discount', getDiscount);

In routes/pricing.mjs:

export function getDiscount(req, res) {
  const { code, total } = req.body;
  const discounts = { SAVE10: 0.1, SAVE20: 0.2 };
  if (!discounts.hasOwnProperty(code)) {
    res.status(500);
    res.json({ error: "invalid code" });
  } else {
    const rate = discounts[code];
    res.json({ discounted: total * (1 - rate) });
  }
}

We are going to be iterating on the pricing module. The first step is to set the base commit with drift init

$ drift init pricing demo/routes/pricing.mjs --description "Base Pricing Module"
drift: initialized release "pricing"
  module: demo/routes/pricing.mjs
  flag:   drift-pricing
  exports: getDiscount, getPrice
  variations: 2

drift-pricing flag created

Drift created a feature flag for us and detected all of the exported functions. Now let's make some changes! We're going to add a new coupon code called SAVE30 and commit.

-  const discounts = { SAVE10: 0.1, SAVE20: 0.2 };
+  const discounts = { SAVE10: 0.1, SAVE20: 0.2, SAVE30: 0.3 };

We can then mark this commit as one we want to be able to control at runtime:

drift mark pricing --name "save30" --description "Add Save 30"
drift: marked variation 2 (save30) at 690d617b

A new variation is created but it's not yet being served:

save 30 variation

Runtime

When you run the application normally, you're just running your local copy of the code. No feature flags are evaluated, in fact: the LaunchDarkly SDK isn't even started up at all. In order to opt-in to runtime control, we can import drift/register:

$ node --import drift/register demo/app.mjs
Demo running on http://localhost:3000

Let's try hitting the discount endpoint:

$ curl -X POST http://localhost:3000/discount \
          -H "Content-Type: application/json" \
          -H "x-user-id: user-123" \
          -d '{"code": "SAVE30", "total": 100}'
{"error":"invalid code"}

So why did we see an error instead of our 30% discount? Drift routed our request to the initial version of this code because we haven't updated the targeting rules yet.

Let's enable it for just our user:

target our user

When we make our request again, we see the discount is available for only us!

$ curl -X POST http://localhost:3000/discount \
         -H "Content-Type: application/json" \
         -H "x-user-id: user-123" \
         -d '{"code": "SAVE30", "total": 100}'
{"discounted":70}

# try again with a different user
$  curl -X POST http://localhost:3000/discount \
              -H "Content-Type: application/json" \
              -H "x-user-id: user-456" \
              -d '{"code": "SAVE30", "total": 100}'
{"error":"invalid code"}

Promotion

The Drift CLI includes a helper to promote a marked version to be the default served to everyone. When you promote a version, the previous default will be set as the off variation. This means we can easily rollback to the previous version by simply toggling the flag off. Drift will also update the fallback value to match the off variation for you. This state is stored as a part of drift's manifest which should be bundled with your deployed application.

$ drift promote pricing save30
drift: promoted "pricing" to variation 2 (save30)

If you want to set the fallback value manually, you can do that with drift fallback <releaseid> <fallback>. If you've had a flag out for a while, you may want to do this to prevent accidental rollbacks:

$ drift fallback pricing save30
drift: fallback for "pricing" set to variation 2 (save30)
  This takes effect on next startup.

You can always see the current state using drift status

$ drift status pricing

pricing
  module:  demo/routes/pricing.mjs
  flag:    drift-pricing
  state:   on
  variations:
    [0] __current "Current"
    [1] 048560fe "Initial" [off]
    [2] 690d617b "save30" (fallback) [default]

When you're using a percentage rollout in the default rule, the weights will be shown:

drift status pricing

pricing
  module:  demo/routes/pricing.mjs
  flag:    drift-pricing
  state:   on
  variations:
    [0] __current "Current" [rollout 0.0%]
    [1] 048560fe "Initial" [off, rollout 60.0%]
    [2] 690d617b "save30" (fallback) [rollout 40.0%]

Monitoring

automatic events

All exported functions in drifted modules are automatically instrumented with error and duration metrics. So out of the box, every single release can be a guarded release.

guarded release

We can take this even further in the future by supporting shadow evaluations for side-effect free functions. For example, imagine you are making changes to your access control system. A future version of drift could evaluate both the current and next versions of your function and compare the results, reporting divergences as error events. While the experiment is running, only values from the current version will be returned to users. This is very similar to a 4-stage migration in LaunchDarkly, just without the writing.

Cleanup

Cleanup is a piece of cake: just run drift archive

drift archive pricing
drift: archived release "pricing"
  Flag archived in LaunchDarkly and release removed from manifest.

If you archived a flag from the UI instead of using the drift cli, you can run drift remove pricing or use the drift cleanup command to check all of the releases in your manifest at once:

$ drift cleanup

Found 1 release(s) with no matching flag:

  pricing (drift-pricing)

Remove "pricing" from manifest? [y/N]

No code changes required, your codebase is always moving ahead. Drift gives your modules the ability to time travel to different versions of your code.

Conclusion

This experiment went better than I could have ever expected. While this system is framework and runtime specific, we can build on the patterns used for automatic instrumentation in APM libraries to speed up implementation. For releases, I think the future is flagless.

I plan to open-source drift in the coming weeks as an experimental project. Bringing it up to a production-grade system will be left as an exercise for somebody else to take on (maybe you?).

RSS
https://tarq.net/posts/atom.xml