Learn-skills.dev feature-flags

Feature flag implementation, management, and best practices for gradual rollouts. Use when user mentions "feature flag", "feature toggle", "feature gate", "canary release", "percentage rollout", "A/B testing", "LaunchDarkly", "Unleash", "Flagsmith", "gradual rollout", "kill switch", "dark launch", "trunk-based development with flags", or controlling feature visibility in production.

install
source · Clone the upstream repo
git clone https://github.com/NeverSight/learn-skills.dev
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/NeverSight/learn-skills.dev "$T" && mkdir -p ~/.claude/skills && cp -r "$T/data/skills-md/1mangesh1/dev-skills-collection/feature-flags" ~/.claude/skills/neversight-learn-skills-dev-feature-flags && rm -rf "$T"
manifest: data/skills-md/1mangesh1/dev-skills-collection/feature-flags/SKILL.md
source content

Feature Flags

Flag Types

TypePurposeLifetimeExample
ReleaseGate incomplete featuresDays-weeks
new-checkout-flow
ExperimentA/B test variationsWeeks-months
pricing-page-variant
Ops / Kill switchRuntime control, emergency disablePermanent-ish
disable-search-indexing
PermissionEntitlement gatingPermanent
premium-analytics

Implementation Patterns

Boolean Flag

if (featureFlags.isEnabled("new-dashboard")) {
  return <NewDashboard />;
}
return <LegacyDashboard />;

Multivariate Flag

const variant = featureFlags.getVariant("checkout-flow");
switch (variant) {
  case "single-page": return <SinglePageCheckout />;
  case "multi-step":  return <MultiStepCheckout />;
  default:            return <ClassicCheckout />;
}

User-Targeted Flag

const context = { userId: user.id, email: user.email, plan: user.plan, country: user.country };
const enabled = featureFlags.isEnabled("beta-feature", context);

SDK Integration

LaunchDarkly

import * as LaunchDarkly from "@launchdarkly/node-server-sdk";
const client = LaunchDarkly.init(process.env.LD_SDK_KEY);
await client.waitForInitialization();
const ctx = { kind: "user", key: user.id, email: user.email, plan: user.plan };
const show = await client.variation("new-feature", ctx, false);
client.on("update:new-feature", () => console.log("Flag changed"));
process.on("SIGTERM", () => client.close());

Unleash

import { initialize } from "unleash-client";
const unleash = initialize({
  url: process.env.UNLEASH_API_URL, appName: "my-app",
  customHeaders: { Authorization: process.env.UNLEASH_API_TOKEN },
});
unleash.on("ready", () => {
  const enabled = unleash.isEnabled("new-feature", { userId: user.id });
});

Flagsmith

import Flagsmith from "flagsmith-nodejs";
const flagsmith = new Flagsmith({ environmentKey: process.env.FLAGSMITH_KEY });
const flags = await flagsmith.getIdentityFlags(user.id, { plan: user.plan });
const enabled = flags.isFeatureEnabled("new-feature");
const value = flags.getFeatureValue("banner-text");

Statsig

import Statsig from "statsig-node";
await Statsig.initialize(process.env.STATSIG_SERVER_KEY);
const enabled = Statsig.checkGate({ userID: user.id, custom: { plan: user.plan } }, "new-feature");

GrowthBook

import { GrowthBook } from "@growthbook/growthbook";
const gb = new GrowthBook({
  apiHost: "https://cdn.growthbook.io",
  clientKey: process.env.GROWTHBOOK_CLIENT_KEY,
  attributes: { id: user.id, plan: user.plan, country: user.country },
  trackingCallback: (exp, result) => {
    analytics.track("experiment_viewed", { experimentId: exp.key, variationId: result.key });
  },
});
await gb.init();
const enabled = gb.isOn("new-feature");

DIY Implementation

Environment Variables

// Simplest approach — binary on/off, requires redeploy to change
function isEnabled(flag: string): boolean {
  return process.env[`FF_${flag.toUpperCase().replace(/-/g, "_")}`] === "true";
}

Database-Backed Flags

CREATE TABLE feature_flags (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100) UNIQUE NOT NULL,
  enabled BOOLEAN DEFAULT false,
  rollout_percentage INT DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100),
  allowed_users TEXT[] DEFAULT '{}',
  allowed_segments TEXT[] DEFAULT '{}',
  metadata JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT now()
);
class FeatureFlagService {
  private cache = new Map<string, { flag: FeatureFlag; expiresAt: number }>();

  async isEnabled(name: string, ctx?: FlagContext): Promise<boolean> {
    const flag = await this.getFlag(name);
    if (!flag?.enabled) return false;
    if (ctx?.userId && flag.allowed_users.includes(ctx.userId)) return true;
    if (ctx?.segment && flag.allowed_segments.includes(ctx.segment)) return true;
    if (flag.rollout_percentage > 0 && flag.rollout_percentage < 100 && ctx?.userId) {
      return (this.hash(ctx.userId, name) % 100) < flag.rollout_percentage;
    }
    return flag.rollout_percentage === 100;
  }

  private hash(userId: string, flagName: string): number {
    let h = 0;
    const s = `${userId}:${flagName}`;
    for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
    return Math.abs(h);
  }

  private async getFlag(name: string): Promise<FeatureFlag | null> {
    const cached = this.cache.get(name);
    if (cached && cached.expiresAt > Date.now()) return cached.flag;
    const flag = await db.query("SELECT * FROM feature_flags WHERE name = $1", [name]);
    if (flag) this.cache.set(name, { flag, expiresAt: Date.now() + 30_000 });
    return flag;
  }
}

Lifecycle Management

Creation

Name flags with ownership:

<team>.<feature>
(e.g.
payments.new-checkout
). Record type, owner, ticket, and expected removal date at creation.

Rollout Schedule

Day 1:  Internal only (allowlist)    Day 5:  10% (monitor metrics)
Day 3:  1% (smoke test)              Day 8:  50% (validate at scale)
Day 10: 100% (full rollout)          Day 14: Remove flag + dead code

Cleanup

# Find stale flags — search for references, compare against flag service
grep -r "isEnabled\|getVariant\|checkGate\|isOn" --include="*.ts" | \
  grep -oP '"[a-z0-9-]+"' | sort -u > used_flags.txt

Warn on overdue flags during evaluation. Log owner and ticket for flags past expected removal date.

Testing with Flags

Unit Tests -- Override Flag Values

describe("Dashboard", () => {
  afterEach(() => featureFlags.clearOverrides());

  it("renders new dashboard when flag is on", () => {
    featureFlags.override("new-dashboard", true);
    expect(render(<Dashboard />).getByTestId("new-dashboard")).toBeInTheDocument();
  });

  it("renders legacy dashboard when flag is off", () => {
    featureFlags.override("new-dashboard", false);
    expect(render(<Dashboard />).getByTestId("legacy-dashboard")).toBeInTheDocument();
  });
});

Integration Tests -- Test All Variants

for (const variant of ["single-page", "multi-step", "control"]) {
  it(`completes purchase with ${variant}`, async () => {
    featureFlags.override("checkout-flow", variant);
    await addItemToCart(testProduct);
    await submitPayment(testCard);
    expect(await getOrderStatus()).toBe("confirmed");
  });
}

Avoid Combinatorial Explosion

// BAD: 2^n tests for n flags (5 flags = 32, 10 flags = 1024)
// GOOD: Test flags independently, plus critical interactions only
const criticalCombos = [
  { "new-checkout": true, "new-payments": true },
  { "new-checkout": true, "new-payments": false },
];
for (const combo of criticalCombos) {
  it(`works with ${JSON.stringify(combo)}`, async () => {
    Object.entries(combo).forEach(([f, v]) => featureFlags.override(f, v));
  });
}

Gradual Rollout Strategies

Percentage-Based (Sticky)

// Deterministic hashing — user always gets same experience
function isInRollout(userId: string, flagName: string, percentage: number): boolean {
  const hash = murmurHash3(`${flagName}:${userId}`) % 100;
  return hash < percentage;
}

User Segments

const segments = {
  "beta-testers": (u) => u.betaOptIn === true,
  "employees":    (u) => u.email.endsWith("@company.com"),
  "power-users":  (u) => u.actionsLast30Days > 100,
  "new-users":    (u) => daysSince(u.createdAt) < 7,
};

Geographic Rollout

const geoRollout = {
  phase1: { regions: ["us-east-1"], percentage: 100 },
  phase2: { regions: ["us-east-1", "eu-west-1"], percentage: 100 },
  phase3: { regions: ["*"], percentage: 100 },
};

Trunk-Based Development with Flags

main ────●────●────●────●────●──── (always deployable)
          |    |    |    |    |
       add   build  wire  ramp  remove flag
       flag  behind  UI   to    + dead code
       stub  flag         100%

Wrap incomplete work behind a flag from the first commit. Push to main daily. Flag stays off in production until ready.

// Commits 1-N: Build behind flag
function SearchResults({ query }) {
  if (featureFlags.isEnabled("new-search")) {
    return <ElasticSearchResults query={query} />;
  }
  return <LegacySearchResults query={query} />;
}

// Cleanup commit: Remove flag and legacy path
function SearchResults({ query }) {
  return <ElasticSearchResults query={query} />;
}

Anti-Patterns

Permanent Release Flags

// BAD: Release flag left for months — becomes invisible tech debt
if (featureFlags.isEnabled("new-header")) { /* added Jan 2024, never removed */ }
// FIX: Set expiry at creation, enforce with CI lint rules

Flag Dependencies

// BAD: Flags that depend on other flags — undefined states when only one is on
if (featureFlags.isEnabled("new-checkout") && featureFlags.isEnabled("new-payments")) {}
// FIX: Single flag controlling related changes together
if (featureFlags.isEnabled("checkout-v2")) {}

Nested Flags

// BAD: 2^3 = 8 hidden states
if (isEnabled("a")) { if (isEnabled("b")) { if (isEnabled("c")) {} } }
// FIX: Flatten to explicit variants
const variant = featureFlags.getVariant("feature-bundle"); // "a" | "ab" | "abc" | "control"

Flags as Configuration

// BAD: Operational params stored as flags
const maxRetries = featureFlags.getVariant("max-retries");
// FIX: Use remote config or env vars for operational parameters
const maxRetries = config.get("maxRetries", 3);

Operational Concerns

Performance and Caching

// Cache flags in memory with background refresh to avoid per-request latency
class CachedFlagClient {
  private cache = new Map<string, { value: any; expiresAt: number }>();
  constructor(private client: FlagClient, private ttl = 30_000) {
    setInterval(() => this.refreshAll(), ttl); // background refresh
  }
  async isEnabled(flag: string, ctx?: FlagContext): Promise<boolean> {
    const c = this.cache.get(flag);
    if (c && c.expiresAt > Date.now()) return c.value;
    return this.evaluate(flag, ctx);
  }
}

Safe Defaults

// System must work when flag service is down
const show = await featureFlags.isEnabled("new-feature", context).catch((err) => {
  logger.error("Flag evaluation failed", { flag: "new-feature", err });
  return false; // safe default: feature off
});

Monitoring and Audit

// Track evaluations for debugging and alerting
metrics.increment("feature_flag.evaluated", { flag: name, result: String(result) });

// Alert on changes
featureFlags.on("change", (flag, oldVal, newVal) => {
  alerting.notify(`Flag "${flag}" changed: ${oldVal} -> ${newVal}`);
});
-- Audit trail for compliance and debugging
CREATE TABLE flag_audit_log (
  id SERIAL PRIMARY KEY,
  flag_name VARCHAR(100) NOT NULL,
  action VARCHAR(20) NOT NULL,  -- created, updated, deleted, toggled
  old_value JSONB, new_value JSONB,
  changed_by VARCHAR(100) NOT NULL, reason TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);