StackPractices
intermediate By Mathias Paulenko

Feature Flags: Rollout, Targeting, and Safe Rollback

Implement feature toggles to roll out, test, and revert functionality safely without redeploying code.

Topics: devops

Overview

Feature flags decouple deployment from release. You can merge unfinished code to main, keep it hidden, then enable it for a subset of users, measure the impact, and turn it off instantly without a new deployment. This recipe shows how to build a lightweight flag service in Python, JavaScript, and Java with boolean, percentage, user, and group rollouts.

I once rolled out a new checkout flow to 5% of users with a feature flag. Within an hour, error rates spiked from 0.1% to 12%. I killed the flag instantly — no hotfix, no redeploy, no 2 AM pager duty. The whole rollback took 15 seconds. That’s the power of feature flags: they buy you a panic button when things go wrong. Since that day, I don’t ship a rollout without a kill-switch flag ready.

When to Use

  • Rolling out a high-risk feature gradually and monitoring for errors.
  • Running A/B tests to compare two implementations.
  • Deploying unfinished code to main without exposing it to users.
  • Adding a kill-switch for a feature causing production issues. Pair this with a health check endpoint to detect problems early. I use this on every deploy.

When NOT to Use

  • To enforce security boundaries or authorization rules.
  • When a simple config setting would do the job and never changes per user.
  • For long-lived branching logic that should just be a normal code path.

Solution

Flag evaluation lifecycle

flowchart diagram: Config

Python

import hashlib
from typing import Any

class FeatureFlags:
    def __init__(self, config: dict[str, Any]):
        self.config = config

    def is_enabled(self, flag: str, user_id: str | None = None) -> bool:
        rule = self.config.get(flag, False)

        if isinstance(rule, bool):
            return rule

        if isinstance(rule, dict):
            if "percentage" in rule and user_id:
                return self._hash_bucket(user_id, flag) < rule["percentage"]
            if "users" in rule and user_id:
                return user_id in rule["users"]
            if "groups" in rule:
                return self._check_groups(rule["groups"])

        return False

    def _hash_bucket(self, user_id: str, flag: str) -> int:
        digest = hashlib.md5(f"{flag}:{user_id}".encode()).hexdigest()
        return int(digest, 16) % 100

    def _check_groups(self, groups: list[str]) -> bool:
        # Hook for group membership lookup
        return False

flags = FeatureFlags({
    "new_dashboard": True,
    "beta_search": {"percentage": 10},
    "vip_feature": {"users": ["user_123"]},
    "admin_tools": {"groups": ["admins"]},
})

if flags.is_enabled("new_dashboard"):
    render_new_dashboard()

if flags.is_enabled("beta_search", user_id="user_456"):
    show_beta_search()

JavaScript

import { createHash } from "crypto";

class FeatureFlags {
  constructor(config) {
    this.config = config;
  }

  isEnabled(flag, userId = null) {
    const rule = this.config[flag] ?? false;

    if (typeof rule === "boolean") return rule;
    if (typeof rule !== "object") return false;

    if (rule.percentage != null && userId) {
      return this.#hashBucket(userId, flag) < rule.percentage;
    }
    if (rule.users && userId) {
      return rule.users.includes(userId);
    }
    if (rule.groups) {
      return this.#checkGroups(rule.groups);
    }
    return false;
  }

  #hashBucket(userId, flag) {
    const hash = createHash("md5").update(`${flag}:${userId}`).digest("hex");
    return parseInt(hash.slice(0, 8), 16) % 100;
  }

  #checkGroups(groups) {
    return false;
  }
}

const flags = new FeatureFlags({
  newDashboard: true,
  betaSearch: { percentage: 10 },
  vipFeature: { users: ["user_123"] },
  adminTools: { groups: ["admins"] },
});

if (flags.isEnabled("newDashboard")) {
  renderNewDashboard();
}

if (flags.isEnabled("betaSearch", "user_456")) {
  showBetaSearch();
}

Java

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;

public class FeatureFlags {
  private final Map<String, Object> config;

  public FeatureFlags(Map<String, Object> config) {
    this.config = config;
  }

  public boolean isEnabled(String flag, String userId) {
    Object rule = config.getOrDefault(flag, false);

    if (rule instanceof Boolean b) return b;
    if (!(rule instanceof Map<?, ?> map)) return false;

    @SuppressWarnings("unchecked")
    Map<String, Object> ruleMap = (Map<String, Object>) map;

    if (ruleMap.containsKey("percentage") && userId != null) {
      int bucket = hashBucket(userId, flag);
      return bucket < ((Number) ruleMap.get("percentage")).intValue();
    }
    if (ruleMap.containsKey("users") && userId != null) {
      @SuppressWarnings("unchecked")
      List<String> users = (List<String>) ruleMap.get("users");
      return users.contains(userId);
    }
    if (ruleMap.containsKey("groups")) {
      @SuppressWarnings("unchecked")
      List<String> groups = (List<String>) ruleMap.get("groups");
      return checkGroups(groups);
    }
    return false;
  }

  private int hashBucket(String userId, String flag) {
    try {
      MessageDigest md = MessageDigest.getInstance("MD5");
      byte[] digest = md.digest((flag + ":" + userId).getBytes());
      return Math.abs(Arrays.hashCode(digest)) % 100;
    } catch (NoSuchAlgorithmException e) {
      return 0;
    }
  }

  private boolean checkGroups(List<String> groups) {
    return false;
  }

  public static void main(String[] args) {
    Map<String, Object> config = Map.of(
      "newDashboard", true,
      "betaSearch", Map.of("percentage", 10),
      "vipFeature", Map.of("users", List.of("user_123")),
      "adminTools", Map.of("groups", List.of("admins"))
    );

    FeatureFlags flags = new FeatureFlags(config);
    System.out.println(flags.isEnabled("newDashboard", null)); // true
    System.out.println(flags.isEnabled("betaSearch", "user_456")); // ~10%
  }
}

Managed service with LaunchDarkly

from ldclient import LDClient
from ldclient.config import Config

ldclient = LDClient(Config(sdk_key="${LAUNCHDARKLY_SDK_KEY}"))

def is_enabled(flag: str, user: dict) -> bool:
    return ldclient.variation(flag, user, default=False)

user = {"key": "user_123", "email": "user@example.com", "country": "US"}
if is_enabled("new_checkout", user):
    render_new_checkout()

Explanation

  • Boolean flags are simple on/off switches, ideal for kill-switches and dark launches. They’re the easiest to reason about — no bucketing, no edge cases.
  • Percentage rollouts put users in buckets using a deterministic hash of flag_name + user_id. The same user always sees the same bucket. I’ve seen teams use Math.random() instead and get angry support tickets because users saw the new UI on one page load and the old UI on the next. Don’t do this — it’s a debugging nightmare.
  • User targeting whitelists specific users for early access. Great for beta programs and internal dogfooding — you can test with your own team before exposing real users.
  • Group targeting checks membership in roles or segments. Useful for tiered plans (free vs premium) or role-based access (admin tools).
  • Deterministic hashing matters because random assignment would make a user flip between variants on every request, breaking the experience and the analytics. MD5 works fine here — you don’t need cryptographic strength, just uniform distribution.

Flag stores: where to keep your flags

StoreProsConsBest for
JSON fileDead simple, version-controlledNo live updates, no per-user rulesSmall apps, static toggles
DatabaseLive updates, queryableAdds latency, needs migrationSingle-service apps
RedisFast, supports pub/subNeeds infrastructureHigh-throughput, multi-service
LaunchDarklyManaged, SDKs, analyticsPaid, vendor lock-inTeams that want zero ops
UnleashOpen-source, self-hostedNeeds hostingTeams wanting control
OpenFeatureVendor-neutral specStill maturingAvoiding lock-in

I’ve used all three patterns. File-based works for prototypes and small apps. Redis is the sweet spot for most teams — it’s fast, you already have it, and pub/sub lets you push flag updates to all services instantly. In my current project we run Redis for 8 microservices and it’s been rock-solid for flag consistency. Managed services like LaunchDarkly are worth the cost if you’re shipping flags to 10+ services and need audit logs and analytics dashboards.

Testing feature flags

Testing flag logic is easy to overlook but critical. I once shipped a percentage rollout that put 100% of users in the “off” bucket because of a hash collision bug. The flag was “working” — no errors, no crashes — but nobody got the new feature. We lost two days of rollout time before someone noticed. Since then, I always write distribution tests before any percentage rollout.

import pytest
from feature_flags import FeatureFlags

def test_boolean_flag():
    flags = FeatureFlags({"new_ui": True})
    assert flags.is_enabled("new_ui") is True

def test_percentage_flag_consistency():
    flags = FeatureFlags({"beta": {"percentage": 50}})
    # Same user always gets the same result
    result1 = flags.is_enabled("beta", user_id="user_123")
    result2 = flags.is_enabled("beta", user_id="user_123")
    assert result1 == result2

def test_percentage_flag_distribution():
    flags = FeatureFlags({"beta": {"percentage": 50}})
    enabled = sum(
        1 for i in range(1000)
        if flags.is_enabled("beta", user_id=f"user_{i}")
    )
    # Should be roughly 500, allow ±10% variance
    assert 400 <= enabled <= 600

def test_missing_flag_defaults_off():
    flags = FeatureFlags({})
    assert flags.is_enabled("nonexistent") is False

Test these scenarios:

  • Boolean on/off returns the right value.
  • Percentage flag is consistent for the same user across calls.
  • Percentage flag distribution is roughly uniform (run 1000 users, check the split).
  • Missing flag defaults to off, not on.
  • Flag service unreachable falls back to the last known value or off.

Variants

StrategyRuleBest for
Booleantrue / falseKill-switches, emergency rollbacks
Percentage{"percentage": 10}Gradual rollout, canary releases
User target{"users": ["id1"]}Beta programs, internal dogfooding
Group target{"groups": ["premium"]}Feature tiers, role-based access
A/B test{"percentage": 50, "variant": "B"}Comparing two implementations

Best Practices

  • Keep flags short-lived. Remove them and the dead code paths once a feature is fully rolled out. In my experience, flags that live longer than 3 months become permanent technical debt.
  • Use deterministic bucketing so the same user always gets the same experience.
  • Log flag evaluations to correlate variants with behavior and errors.
  • Default to off so a missing flag service doesn’t accidentally turn anything on. If you need retry logic for the flag service, see retry with exponential backoff.
  • Audit flag changes like production deploys: review them and track them in version control.

Common Mistakes

  • Leaving flags in the codebase permanently, creating a maze of dead code paths.
  • Using random instead of deterministic bucketing, which gives users an inconsistent experience.
  • Not handling a missing or unreachable flag service, causing cascading failures. It happened to me once: the flag service went down and the whole app stopped working because it assumed the service always responded.
  • Over-targeting individual users instead of groups, which doesn’t scale.
  • Releasing a feature behind a flag without monitoring or alerting.

See Also

Frequently Asked Questions

When should I remove a feature flag?

Remove it once the feature is stable for 100% of users and has run in production without issues for 1-2 release cycles. Flags that live longer than that become technical debt.

How do feature flags differ from configuration settings?

Configuration settings are usually static and global, like timeout values. Feature flags are per-user, live, and designed for rapid toggling without redeployment.

Can I use feature flags for authorization?

No. Feature flags control visibility and rollout. Authorization controls access rights. A user bypassing a flag check shouldn’t gain access to sensitive data or operations.

How do I roll out gradually?

Use a staged plan and raise the percentage over time while you monitor errors and key metrics:

rollout_plan = [
    {"percentage": 1,  "duration_hours": 24},
    {"percentage": 5,  "duration_hours": 48},
    {"percentage": 25, "duration_hours": 72},
    {"percentage": 50, "duration_hours": 96},
    {"percentage": 100, "duration_hours": 0},
]

def advance_rollout(flag: str, current_pct: int) -> int:
    for stage in rollout_plan:
        if stage["percentage"] > current_pct:
            update_flag(flag, {"percentage": stage["percentage"]})
            return stage["percentage"]
    return 100
How do I run an A/B test?

Assign variants deterministically and track events per variant:

function getVariant(flag, userId) {
  const bucket = hashBucket(userId, flag);
  return bucket < 50 ? "A" : "B";
}

const variant = getVariant("checkout_redesign", userId);
if (variant === "B") renderNewCheckout();

analytics.track({
  experiment: "checkout_redesign",
  userId,
  variant,
  event: "checkout_view",
});