Deploy — Feature Flags & Progressive Delivery
Feature flags (also called feature toggles or feature switches) decouple code deployment from feature release. They let you deploy code to production and enable features selectively — to specific users, cohorts, or percentages — without a new deployment.
This section covers flag types, management platforms (LaunchDarkly, Unleash, Flagsmith), the OpenFeature standard, canary and A/B testing patterns, and SDK integration for React and Node.js.
A feature flag is a boolean or configuration value that controls whether a piece of code is executed. At its simplest, it is an if-statement backed by an external configuration source that can be changed at runtime without redeploying.
| 1 | // Simple boolean flag |
| 2 | const enableNewCheckout = flags.isEnabled("new-checkout-flow"); |
| 3 | |
| 4 | if (enableNewCheckout) { |
| 5 | return renderNewCheckout(props); |
| 6 | } else { |
| 7 | return renderLegacyCheckout(props); |
| 8 | } |
| 9 | |
| 10 | // Flag with default value (fail-open) |
| 11 | const darkMode = flags.isEnabled("dark-mode", { defaultValue: false }); |
| 12 | |
| 13 | // Flag targeting specific users |
| 14 | const betaFeature = flags.isEnabled("beta-feature", { |
| 15 | userId: user.id, |
| 16 | attributes: { |
| 17 | plan: user.plan, |
| 18 | region: user.region, |
| 19 | }, |
| 20 | }); |
info
Not all flags serve the same purpose. Categorizing flags by their lifecycle and purpose helps manage complexity and prevents flag debt.
| Type | Purpose | Lifecycle |
|---|---|---|
| Release | Gate new features during rollout | Short-lived (days/weeks) |
| Experiment | A/B test variations for optimization | Medium (weeks/months) |
| Ops | Kill switches, maintenance mode, rate limiting | Long-lived (permanent) |
| Permission | Control access based on plan, role, or entitlements | Long-lived |
warning
Managed feature flag platforms provide dashboards, SDKs, audit logs, and targeting engines. Self-hosted options give you full control over flag data.
| Platform | Type | Best For |
|---|---|---|
| LaunchDarkly | Managed SaaS | Enterprise teams needing advanced targeting, audit, and compliance |
| Unleash | Open-source / Managed | Teams wanting self-hosted with full control |
| Flagsmith | Open-source / Managed | Simple, clean API with self-hosting option |
| OpenFeature | Vendor-neutral API | Avoiding vendor lock-in with a standard interface |
OpenFeature is a vendor-neutral standard for feature flagging. It provides a unified API that works with any flag provider, so you can switch providers without changing application code.
| 1 | import { OpenFeature } from "@openfeature/js-sdk"; |
| 2 | |
| 3 | // Initialize with a provider (LaunchDarkly, Unleash, etc.) |
| 4 | await OpenFeature.setProvider(new YourFlagProvider({ |
| 5 | apiKey: process.env.FLAG_API_KEY, |
| 6 | })); |
| 7 | |
| 8 | const client = OpenFeature.getClient("my-app", "production"); |
| 9 | |
| 10 | // Evaluate a boolean flag |
| 11 | const showNewUI = await client.getBooleanValue("new-ui", false); |
| 12 | |
| 13 | // Evaluate a string flag (for variations) |
| 14 | const checkoutVersion = await client.getStringValue( |
| 15 | "checkout-experiment", |
| 16 | "control", |
| 17 | { |
| 18 | targetingKey: user.id, |
| 19 | plan: user.plan, |
| 20 | } |
| 21 | ); |
| 22 | |
| 23 | // Evaluate a number flag (e.g., max items) |
| 24 | const maxItems = await client.getNumberValue("max-cart-items", 10); |
best practice
Feature flags enable gradual rollouts — releasing features to a small percentage of users first, monitoring for errors, and expanding gradually. This is also known as canary releasing.
| 1 | // Gradual rollout: percentage-based targeting |
| 2 | const config = { |
| 3 | flag: "new-search-algorithm", |
| 4 | rules: [ |
| 5 | { percentage: 5, variation: "enabled" }, // 5% get the new feature |
| 6 | { percentage: 100, variation: "disabled" }, // rest get the old one |
| 7 | ], |
| 8 | }; |
| 9 | |
| 10 | // Stage-based rollout |
| 11 | const stages = [ |
| 12 | { percent: 1, label: "canary", duration: "2h" }, |
| 13 | { percent: 10, label: "early", duration: "4h" }, |
| 14 | { percent: 25, label: "beta", duration: "24h" }, |
| 15 | { percentage: 100, label: "ga", duration: "permanent" }, |
| 16 | ]; |
| 17 | |
| 18 | // Monitor error rate at each stage before advancing |
| 19 | async function advanceRollout(flagName: string, stage: number) { |
| 20 | const errorRate = await getErrorRate(flagName); |
| 21 | if (errorRate > 0.01) { // > 1% error rate |
| 22 | await disableFlag(flagName); |
| 23 | await alertOnCall("Rollback triggered for " + flagName); |
| 24 | return; |
| 25 | } |
| 26 | if (stage < stages.length - 1) { |
| 27 | await setFlagPercentage(flagName, stages[stage + 1].percent); |
| 28 | await scheduleAdvance(flagName, stage + 1, stages[stage].duration); |
| 29 | } |
| 30 | } |
info
A/B testing uses feature flags with user segmentation to test variations of a feature and measure their impact on key metrics. Proper A/B testing requires consistent user bucketing and statistical rigor.
| 1 | // A/B test: two variations of a pricing page |
| 2 | const client = OpenFeature.getClient("my-app"); |
| 3 | |
| 4 | async function getPricingPage(userId: string) { |
| 5 | const variation = await client.getStringValue( |
| 6 | "pricing-page-test", |
| 7 | "control", |
| 8 | { targetingKey: userId } |
| 9 | ); |
| 10 | |
| 11 | // Track exposure for analysis |
| 12 | trackExposure("pricing-page-test", variation, userId); |
| 13 | |
| 14 | return variation === "control" |
| 15 | ? renderCurrentPricing() |
| 16 | : renderNewPricing(); |
| 17 | } |
| 18 | |
| 19 | // Analyze results after experiment concludes |
| 20 | function analyzeResults(exposures: Exposure[]) { |
| 21 | const control = exposures.filter(e => e.variation === "control"); |
| 22 | const treatment = exposures.filter(e => e.variation === "treatment"); |
| 23 | |
| 24 | const controlConversion = control.filter(e => e.converted).length / control.length; |
| 25 | const treatmentConversion = treatment.filter(e => e.converted).length / treatment.length; |
| 26 | |
| 27 | const uplift = (treatmentConversion - controlConversion) / controlConversion; |
| 28 | const pValue = calculatePValue(control, treatment); |
| 29 | |
| 30 | return { uplift, pValue, significant: pValue < 0.05 }; |
| 31 | } |
Every flag should follow a clear lifecycle: create, evaluate, and remove. Flags that are never removed create maintenance burden and hide dead code.
| Phase | Actions |
|---|---|
| Create | Define flag, set type (release/experiment/ops), document purpose and owner |
| Evaluate | Deploy code with flag, enable gradually, monitor metrics |
| Remove | Once fully rolled out, remove the flag and dead code, close the ticket |
warning
Feature flag SDKs are available for every major platform. Here is how to integrate flags in React and Node.js.
| 1 | // React: Wrap app with OpenFeature provider |
| 2 | "use client"; |
| 3 | import { OpenFeatureProvider, useFlag } from "@openfeature/js-sdk-react"; |
| 4 | |
| 5 | function App() { |
| 6 | return ( |
| 7 | <OpenFeatureProvider client={client}> |
| 8 | <Dashboard /> |
| 9 | </OpenFeatureProvider> |
| 10 | ); |
| 11 | } |
| 12 | |
| 13 | // Use flags in components |
| 14 | function Dashboard() { |
| 15 | const { value: showAnalytics } = useFlag("analytics-widget", false); |
| 16 | const { value: layout } = useFlag("dashboard-layout", "grid"); |
| 17 | |
| 18 | return ( |
| 19 | <div className={layout}> |
| 20 | {showAnalytics && <AnalyticsWidget />} |
| 21 | <RecentActivity /> |
| 22 | </div> |
| 23 | ); |
| 24 | } |
| 1 | // Node.js: Server-side flag evaluation |
| 2 | import { OpenFeature } from "@openfeature/js-sdk"; |
| 3 | |
| 4 | const client = OpenFeature.getClient("api-server"); |
| 5 | |
| 6 | app.get("/api/products", async (req, res) => { |
| 7 | const useNewQuery = await client.getBooleanValue( |
| 8 | "new-product-query", |
| 9 | false, |
| 10 | { targetingKey: req.user?.id ?? "anonymous" } |
| 11 | ); |
| 12 | |
| 13 | const products = useNewQuery |
| 14 | ? await fetchProductsWithNewQuery() |
| 15 | : await fetchProductsWithLegacyQuery(); |
| 16 | |
| 17 | res.json(products); |
| 18 | }); |
Consistent naming conventions make flags discoverable and self-documenting. Use a hierarchical naming scheme that encodes ownership and type.
| 1 | // Convention: [team].[feature].[variant] |
| 2 | // Examples: |
| 3 | const flags = { |
| 4 | // Release flags |
| 5 | "payments.new-checkout-flow": true, |
| 6 | "search.v2-ranking-algorithm": false, |
| 7 | |
| 8 | // Experiment flags |
| 9 | "growth.pricing-page-v2": "treatment", |
| 10 | "onboarding.tutorial-style": "interactive", |
| 11 | |
| 12 | // Ops flags |
| 13 | "platform.maintenance-mode": false, |
| 14 | "api.rate-limit-enabled": true, |
| 15 | |
| 16 | // Permission flags |
| 17 | "billing.export-csv": true, // plan-based |
| 18 | "admin.advanced-analytics": true, // role-based |
| 19 | }; |
best practice
Testing code behind flags requires testing both the enabled and disabled paths. Mock the flag provider in tests to control which code paths execute.
| 1 | // Jest: Mock flag provider for deterministic tests |
| 2 | import { InMemoryProvider, OpenFeature } from "@openfeature/js-sdk"; |
| 3 | |
| 4 | beforeEach(() => { |
| 5 | const provider = new InMemoryProvider({ |
| 6 | "new-checkout-flow": { value: true }, |
| 7 | "dark-mode": { value: false }, |
| 8 | }); |
| 9 | OpenFeature.setProvider(provider); |
| 10 | }); |
| 11 | |
| 12 | test("uses new checkout when flag is enabled", async () => { |
| 13 | const client = OpenFeature.getClient("test"); |
| 14 | const result = await client.getBooleanValue("new-checkout-flow", false); |
| 15 | expect(result).toBe(true); |
| 16 | }); |
| 17 | |
| 18 | test("uses legacy checkout when flag is disabled", async () => { |
| 19 | const provider = new InMemoryProvider({ |
| 20 | "new-checkout-flow": { value: false }, |
| 21 | }); |
| 22 | OpenFeature.setProvider(provider); |
| 23 | |
| 24 | const client = OpenFeature.getClient("test"); |
| 25 | const result = await client.getBooleanValue("new-checkout-flow", false); |
| 26 | expect(result).toBe(false); |
| 27 | }); |
✓ Limit flag scope
Use flags for small, well-defined features. Avoid flags that control large swaths of code — they become impossible to test comprehensively.
✓ Remove flags after full rollout
Set a deadline for flag removal when you create it. Use automated reminders and cleanup sprints to prevent flag debt.
✓ Use flag defaults for offline resilience
Always specify a safe default value. If the flag provider is unreachable, the application should degrade gracefully to the default state.
✓ Test both flag states
Every flag creates a new code path. Write tests for both the enabled and disabled states to ensure neither path is broken.