Feature flags aren’t just about toggles — they evolve over time. A flag might start as a boolean, later switch to an expression, and eventually get removed. Without a versioning strategy, things break: stale configs, incompatible logic, and orphaned code paths.

In this article, you’ll learn how to build a versioned feature flag system in JavaScript that can safely evolve — without breaking your app.


Step 1: Version Your Flags Explicitly

Start by defining each flag with a version field:


const fallbackFlags = {
  newUI: {
    version: 1,
    expr: "user.plan === 'pro'",
  },
  searchBoost: {
    version: 2,
    expr: "getBucket(user.id) < 30",
  },
};

This allows your app to know what format or logic it's dealing with.


Step 2: Handle Version Migrations at Load Time

When loading remote or cached flags, normalize and upgrade them if needed:


function migrateFlag(flag, currentVersion) {
  const upgraded = { ...flag };

  if (!flag.version || flag.version < currentVersion) {
    // Example: older flags used plain booleans
    if (typeof flag === "boolean") {
      upgraded.expr = flag ? "true" : "false";
    }

    upgraded.version = currentVersion;
  }

  return upgraded;
}

Wrap this into your loading logic so all flags are upgraded before evaluation.


Step 3: Avoid Breaking Changes by Supporting Legacy Versions

If your app may receive old snapshots (e.g. from offline clients), support evaluation of multiple versions:


function evaluateFlag(flag, user, context = {}) {
  try {
    if (flag.version === 1) {
      const fn = new Function('user', `return (${flag.expr})`);
      return !!fn(user);
    }

    if (flag.version === 2) {
      const fn = new Function('user', 'context', 'getBucket', `return (${flag.expr})`);
      return !!fn(user, context, getBucket);
    }

    return false;
  } catch {
    return false;
  }
}

You can remove support for old versions gradually, once they’re no longer needed.


Step 4: Track Flag Cleanup with Metadata

Add optional metadata to track rollout state:


{
  searchBoost: {
    version: 2,
    expr: "getBucket(user.id) < 30",
    deprecated: false,
    rolloutStatus: "active"
  }
}

This helps you audit flags later and safely remove them when they're no longer needed.


Step 5: Persist and Load Flag Snapshots with Version Checks

When storing snapshots (e.g. in localStorage or IndexedDB), include a schema or timestamp:


function storeFlags(flags) {
  localStorage.setItem('flagSnapshot', JSON.stringify({
    schemaVersion: 2,
    timestamp: Date.now(),
    flags,
  }));
}

This ensures you don’t accidentally load outdated or incompatible formats later.


Pros:

  • 🔢 Explicit flag versions prevent runtime errors
  • 🔄 Allows smooth migrations as logic evolves
  • 📦 Supports old clients without breaking new features
  • ✅ Easy to audit and clean up stale flags

⚠️ Cons:

  • 🛠 Slightly more complexity to manage versions
  • 📉 Can bloat your config if you don’t clean up
  • 🧹 Requires discipline to deprecate and remove old logic

Summary

A versioned feature flag system keeps your app flexible without sacrificing safety. By migrating flags, evaluating based on version, and tracking metadata, you can build a flag system that evolves alongside your product — and never breaks during rollout.


Want to learn much more? My full guide explains in detail how to:

  • Use JavaScript expressions for safe feature flag evaluation
  • Handle gradual feature rollouts and exposure
  • Implement flag versioning, migration strategies, and more
  • Design a feature flagging system that works offline and is resilient to failure

Feature Flag Engineering Like a Pro: From JS Expressions to Global Rollouts — just $10 on Gumroad.

If this was helpful, you can also support me here: Buy Me a Coffee