SaaS Feature Flags Advanced: Targeting Rules, Gradual Rollout, Kill Switches, and Analytics
Implement advanced feature flags for SaaS: targeting rules by user attributes, percentage rollouts with sticky assignment, kill switches for incident response, flag dependency graphs, and rollout analytics.
Basic feature flags are boolean switches. Advanced feature flags are a deployment and experimentation platform: gradual rollouts to 5% of users, targeting specific plans or geographies, kill switches that disable features in under 1 second during incidents, flag dependencies that prevent enabling a feature before its prerequisite, and analytics that track which flags correlate with activation.
This post covers building a production feature flag system: flag definition schema, targeting rule evaluation, sticky percentage rollout, kill switch architecture with Redis pub/sub for instant propagation, and flag analytics.
System Design
Flag definition (DB):
flag "new-checkout"
โโโ targeting rule: plan = "pro" โ 100%
โโโ targeting rule: beta_users = true โ 100%
โโโ rollout: all other users โ 15%
โโโ kill switch: disabled โ 0%
Evaluation (in-process, < 1ms):
user context: { userId, plan, country, betaUser }
โ evaluate rules top-down โ first match wins โ return variant
Kill switch (Redis pub/sub):
admin disables flag โ publish to Redis โ all instances update in-memory cache
1. Database Schema
CREATE TABLE feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'off'
CHECK (status IN ('off', 'on', 'rollout')),
-- Targeting rules (evaluated top-to-bottom)
rules JSONB NOT NULL DEFAULT '[]',
-- Default rollout percentage (0โ100) for users matching no rule
rollout_pct INTEGER NOT NULL DEFAULT 0 CHECK (rollout_pct BETWEEN 0 AND 100),
-- Variants (for multivariate flags)
variants JSONB NOT NULL DEFAULT '["on","off"]',
-- Metadata
owner TEXT,
expires_at TIMESTAMPTZ, -- Auto-cleanup reminder
tags TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Evaluation log for analytics (write async, read for reports)
CREATE TABLE flag_evaluations (
id BIGSERIAL PRIMARY KEY,
flag_key TEXT NOT NULL,
user_id UUID,
tenant_id UUID,
variant TEXT NOT NULL,
rule_matched TEXT, -- which rule triggered, or 'rollout', 'default'
evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (evaluated_at);
CREATE TABLE flag_evaluations_2026_12
PARTITION OF flag_evaluations
FOR VALUES FROM ('2026-12-01') TO ('2027-01-01');
-- Index for analytics queries
CREATE INDEX idx_flag_evals_key_date ON flag_evaluations (flag_key, evaluated_at DESC);
๐ SaaS MVP in 8 Weeks โ Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ all handled by one senior team.
- Week 1โ2: Architecture design + wireframes
- Week 3โ6: Core features built + tested
- Week 7โ8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
2. Flag Definition and Rule Structure
// src/lib/flags/types.ts
export type FlagVariant = string; // 'on' | 'off' | 'variant-a' | 'variant-b'
export interface TargetingRule {
id: string;
description?: string;
conditions: Condition[];
variant: FlagVariant;
rolloutPct: number; // Within matched users, what % get this variant (0โ100)
}
export type Condition =
| { attribute: 'plan'; operator: 'in'; values: string[] }
| { attribute: 'country'; operator: 'in' | 'not_in'; values: string[] }
| { attribute: 'userId'; operator: 'in'; values: string[] } // Named users
| { attribute: string; operator: 'eq' | 'neq'; value: string | boolean | number }
| { attribute: string; operator: 'gt' | 'lt' | 'gte' | 'lte'; value: number };
export interface FlagDefinition {
key: string;
name: string;
status: 'off' | 'on' | 'rollout';
rules: TargetingRule[];
rolloutPct: number; // Default rollout for unmatched users
variants: FlagVariant[];
}
export interface EvaluationContext {
userId: string;
tenantId: string;
plan: string;
country?: string;
betaUser?: boolean;
createdAt?: Date;
[key: string]: unknown; // Custom attributes
}
3. Flag Evaluator
// src/lib/flags/evaluator.ts
import { createHash } from 'crypto';
import type { FlagDefinition, EvaluationContext, TargetingRule, Condition, FlagVariant } from './types';
export interface EvaluationResult {
variant: FlagVariant;
ruleMatched: string | null;
isEnabled: boolean;
}
export function evaluateFlag(
flag: FlagDefinition,
ctx: EvaluationContext
): EvaluationResult {
// Kill switch: flag is off for everyone
if (flag.status === 'off') {
return { variant: 'off', ruleMatched: 'kill-switch', isEnabled: false };
}
// Flag is fully on for everyone
if (flag.status === 'on') {
return { variant: 'on', ruleMatched: 'global-on', isEnabled: true };
}
// Evaluate targeting rules (first match wins)
for (const rule of flag.rules) {
if (!matchesConditions(rule.conditions, ctx)) continue;
// Check percentage rollout within matched segment
const bucket = getBucket(ctx.userId, flag.key, rule.id);
if (bucket < rule.rolloutPct) {
return {
variant: rule.variant,
ruleMatched: rule.id,
isEnabled: rule.variant !== 'off',
};
}
}
// Default rollout: applies to users matching no rule
const bucket = getBucket(ctx.userId, flag.key, 'default');
if (bucket < flag.rolloutPct) {
return { variant: 'on', ruleMatched: 'rollout', isEnabled: true };
}
return { variant: 'off', ruleMatched: 'default', isEnabled: false };
}
function matchesConditions(conditions: Condition[], ctx: EvaluationContext): boolean {
return conditions.every((cond) => {
const value = ctx[cond.attribute];
switch (cond.operator) {
case 'in':
return Array.isArray(cond.values) && cond.values.includes(value as string);
case 'not_in':
return Array.isArray(cond.values) && !cond.values.includes(value as string);
case 'eq':
return value === cond.value;
case 'neq':
return value !== cond.value;
case 'gt':
return typeof value === 'number' && value > (cond.value as number);
case 'lt':
return typeof value === 'number' && value < (cond.value as number);
case 'gte':
return typeof value === 'number' && value >= (cond.value as number);
case 'lte':
return typeof value === 'number' && value <= (cond.value as number);
default:
return false;
}
});
}
// Deterministic bucket: same userId + flagKey always โ same bucket (sticky)
function getBucket(userId: string, flagKey: string, ruleId: string): number {
const hash = createHash('sha256')
.update(`${userId}:${flagKey}:${ruleId}`)
.digest('hex');
// Take first 4 bytes as uint32, normalize to 0โ100
const num = parseInt(hash.slice(0, 8), 16);
return (num / 0xffffffff) * 100;
}
๐ก The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity โ you own everything
4. Flag Cache with Redis Kill Switch
// src/lib/flags/flag-client.ts
import { Redis } from 'ioredis';
import { db } from '../db';
import { evaluateFlag } from './evaluator';
import type { FlagDefinition, EvaluationContext, EvaluationResult } from './types';
const CACHE_TTL_MS = 60_000; // 1 minute โ flags reload automatically
class FlagClient {
private cache = new Map<string, { flag: FlagDefinition; expiresAt: number }>();
private subscriber: Redis;
constructor() {
// Subscribe to flag invalidation events
this.subscriber = new Redis({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT ?? 6379),
});
this.subscriber.subscribe('flag-invalidate');
this.subscriber.on('message', (_channel, flagKey) => {
// Immediately evict from cache on kill switch or update
this.cache.delete(flagKey);
console.log(`Flag cache invalidated: ${flagKey}`);
});
}
async evaluate(flagKey: string, ctx: EvaluationContext): Promise<EvaluationResult> {
const flag = await this.getFlag(flagKey);
if (!flag) {
return { variant: 'off', ruleMatched: 'not-found', isEnabled: false };
}
const result = evaluateFlag(flag, ctx);
// Log evaluation async (don't block the response)
this.logEvaluation(flagKey, ctx, result).catch(console.error);
return result;
}
async isEnabled(flagKey: string, ctx: EvaluationContext): Promise<boolean> {
const { isEnabled } = await this.evaluate(flagKey, ctx);
return isEnabled;
}
private async getFlag(flagKey: string): Promise<FlagDefinition | null> {
const cached = this.cache.get(flagKey);
if (cached && Date.now() < cached.expiresAt) {
return cached.flag;
}
const row = await db.featureFlag.findUnique({ where: { key: flagKey } });
if (!row) return null;
const flag = row as unknown as FlagDefinition;
this.cache.set(flagKey, { flag, expiresAt: Date.now() + CACHE_TTL_MS });
return flag;
}
private async logEvaluation(
flagKey: string,
ctx: EvaluationContext,
result: EvaluationResult
): Promise<void> {
// Batch writes โ don't write one row per evaluation
// Use a buffer + flush every 5 seconds
evaluationBuffer.push({
flagKey,
userId: ctx.userId,
tenantId: ctx.tenantId,
variant: result.variant,
ruleMatched: result.ruleMatched,
});
}
}
export const flags = new FlagClient();
// Publish kill switch / update (call from admin API after DB update)
export async function invalidateFlag(flagKey: string): Promise<void> {
const publisher = new Redis({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT ?? 6379),
});
await publisher.publish('flag-invalidate', flagKey);
await publisher.quit();
}
5. Usage in Application Code
// In API routes / Server Components
import { flags } from '../../lib/flags/flag-client';
export async function GET(req: Request) {
const session = await getServerSession();
const ctx = {
userId: session.user.id,
tenantId: session.user.tenantId,
plan: session.user.plan,
country: req.headers.get('cf-ipcountry') ?? 'US',
betaUser: session.user.isBetaUser,
};
const [newCheckout, aiSuggestions] = await Promise.all([
flags.isEnabled('new-checkout', ctx),
flags.isEnabled('ai-suggestions', ctx),
]);
return Response.json({ features: { newCheckout, aiSuggestions } });
}
// React Server Component
export default async function CheckoutPage() {
const session = await getServerSession();
const ctx = buildFlagContext(session.user);
const useNewCheckout = await flags.isEnabled('new-checkout', ctx);
return useNewCheckout ? <NewCheckout /> : <LegacyCheckout />;
}
6. Flag Analytics
-- Flag evaluation rates and variant distribution
SELECT
flag_key,
DATE_TRUNC('day', evaluated_at) AS day,
variant,
COUNT(*) AS evaluations,
COUNT(DISTINCT user_id) AS unique_users,
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (PARTITION BY flag_key, DATE_TRUNC('day', evaluated_at)), 1) AS pct
FROM flag_evaluations
WHERE evaluated_at >= NOW() - INTERVAL '7 days'
GROUP BY flag_key, DATE_TRUNC('day', evaluated_at), variant
ORDER BY flag_key, day DESC, pct DESC;
-- Which flags are stale (not evaluated in 30 days)?
SELECT f.key, f.name, f.owner, f.expires_at,
MAX(e.evaluated_at) AS last_evaluated
FROM feature_flags f
LEFT JOIN flag_evaluations e ON f.key = e.flag_key
AND e.evaluated_at >= NOW() - INTERVAL '30 days'
GROUP BY f.key, f.name, f.owner, f.expires_at
HAVING MAX(e.evaluated_at) IS NULL
ORDER BY f.created_at;
Cost Reference
| Approach | Monthly cost | Evaluation latency | Flag limit |
|---|---|---|---|
| Build your own (this post) | $0 (infra only) | < 1ms (in-process) | Unlimited |
| LaunchDarkly (Starter) | $12/seat | < 1ms (SDK) | Unlimited |
| LaunchDarkly (Enterprise) | Custom | < 1ms | Unlimited |
| Unleash (self-hosted) | $0 (infra only) | < 1ms | Unlimited |
| Flagsmith (Cloud) | $45/mo | < 10ms (API) | Unlimited |
See Also
- SaaS Onboarding Checklist: Interactive UI and Completion Rewards
- SaaS Audit Logging: Immutable Trails and SOC2 Compliance
- Redis Advanced Patterns: Pub/Sub, Streams, and Lua Scripts
- Product Analytics Engineering: Tracking, Funnels, and Retention
- TypeScript Branded Types: Nominal Typing and Type-Safe IDs
Working With Viprasol
Need feature flags that support gradual rollouts, plan-based targeting, and kill switches that propagate in under a second across all instances? We build and integrate feature flag systems โ from in-process evaluators to Redis pub/sub invalidation โ with flag analytics so you know which features drive activation and which are safe to clean up.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow โ automating repetitive tasks, qualifying leads, and responding across every channel your customers use.