Feature flags are one of those tools that sound simple but accumulate complexity quickly. LaunchDarkly solves the hard version of the problem with a sophisticated SDK, real-time streaming updates, A/B experiment tracking, and a polished UI. It also starts at around $300/month and scales from there. For the majority of SaaS companies — especially those in the zero-to-$1M ARR range — this is a significant spend on a problem that is actually straightforward to solve yourself.
The flag taxonomy
Before building or buying, it helps to be precise about what kinds of flags you actually need. They fall into four categories with very different evaluation logic:
Boolean flags
The simplest type. A feature is either on or off, globally. Used for kill switches, incident response, and staged rollouts that are either fully shipped or fully reverted. Store as a key-value pair: { "key": "new_billing_ui", "enabled": true }.
Percentage rollout flags
Enable a feature for N% of users. The critical requirement is consistency: a given user must see the same flag value across requests, devices, and time. This requires a deterministic hash of the user ID and flag key, not a random coin flip per request. The standard approach is murmurhash(userId + flagKey) % 100 < rolloutPercentage.
User segment flags
Enable a feature for a specific list of user IDs, tenant IDs, or email domains. Used for beta programs, early access, and testing with specific customers. The evaluation logic is a set membership check.
Plan/tier flags
Enable features based on subscription tier. This is your pricing gate — Pro users see feature X, Starter users do not. These flags read from the user's subscription state in your database rather than a separate flag store.
Building your own flag system
A flag system has two components: a storage layer and an evaluation SDK. The storage layer holds flag configurations; the SDK evaluates flags in your application code given a user context.
-- Flag storage schema
CREATE TABLE feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_pct INTEGER CHECK (rollout_pct BETWEEN 0 AND 100),
allowed_user_ids TEXT[], -- specific user IDs
allowed_tenant_ids TEXT[], -- specific tenant IDs
allowed_plans TEXT[], -- plan names: 'pro', 'enterprise'
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
// Flag evaluation SDK (TypeScript)
import murmurhash from 'murmurhash';
interface UserContext {
userId: string;
tenantId: string;
plan: string;
email?: string;
}
interface FlagConfig {
key: string;
enabled: boolean;
rolloutPct?: number;
allowedUserIds?: string[];
allowedTenantIds?: string[];
allowedPlans?: string[];
}
class FeatureFlags {
private flags: Map<string, FlagConfig>;
constructor(flags: FlagConfig[]) {
this.flags = new Map(flags.map(f => [f.key, f]));
}
isEnabled(flagKey: string, user: UserContext): boolean {
const flag = this.flags.get(flagKey);
if (!flag) return false; // unknown flags default to off
if (!flag.enabled) return false; // globally disabled
// User-specific overrides (highest priority)
if (flag.allowedUserIds?.includes(user.userId)) return true;
// Tenant-level overrides
if (flag.allowedTenantIds?.includes(user.tenantId)) return true;
// Plan-based access
if (flag.allowedPlans?.length) {
if (!flag.allowedPlans.includes(user.plan)) return false;
}
// Percentage rollout (deterministic by user)
if (flag.rolloutPct !== undefined && flag.rolloutPct < 100) {
const hash = murmurhash.v3(`${user.userId}:${flagKey}`) % 100;
return hash < flag.rolloutPct;
}
return true;
}
}
// Usage
const flags = new FeatureFlags(await db.query('SELECT * FROM feature_flags'));
if (flags.isEnabled('new_dashboard', req.user)) {
return renderNewDashboard();
} else {
return renderLegacyDashboard();
}
Caching and refresh
The flags store is read on virtually every request. Without caching, this becomes a database bottleneck. A practical approach is to cache the full flag configuration in Redis with a 30-second TTL. This means flag changes propagate within 30 seconds — fast enough for most use cases including incident response.
class CachedFeatureFlags {
private redis: Redis;
private cacheKey = 'feature_flags:v1';
private cacheTTL = 30; // seconds
async getFlags(): Promise<FlagConfig[]> {
const cached = await this.redis.get(this.cacheKey);
if (cached) return JSON.parse(cached);
const flags = await db.query('SELECT * FROM feature_flags');
await this.redis.setex(this.cacheKey, this.cacheTTL, JSON.stringify(flags));
return flags;
}
async invalidate() {
await this.redis.del(this.cacheKey);
}
}
If you need sub-second propagation (for incident kill-switch scenarios), use Redis pub/sub to broadcast flag changes to all app instances in real time, which is what LaunchDarkly's streaming SDK does under the hood.
LaunchDarkly's actual advantages
The self-built approach covers 80% of use cases. LaunchDarkly's value proposition is strongest for:
- A/B testing infrastructure: Statistical significance calculations, conversion tracking, automatic experiment analysis. Building this correctly is genuinely hard.
- Real-time flag updates at scale: Streaming updates to millions of client-side SDK instances simultaneously. At this scale, maintaining SSE infrastructure per-app is non-trivial.
- Audit logging: SOC 2-grade audit trail of every flag change, who made it, and when. Compliance teams love this.
- Multi-environment management: Separating staging and production flag states with a promotion workflow. Manageable to build yourself but tedious.
If your team is running structured A/B experiments regularly with statistical analysis, or if you have a compliance requirement for flag change audit trails, LaunchDarkly or a comparable managed solution (Statsig, Unleash) is worth the cost. Below 50 engineers and without these specific needs, the self-built approach is the right call.
Bastionary's built-in flags
Bastionary includes a flag evaluation system built on top of the identity and tenant model. Flags can reference plan tier, SCIM-provisioned group membership, or specific user/tenant IDs directly from your auth context, without a separate SDK integration. Flag values are embedded in the access token claims if configured, meaning your frontend can evaluate flags without an additional API call at load time.