Back to Blog

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.

Viprasol Tech Team
December 14, 2026
13 min read

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

ApproachMonthly costEvaluation latencyFlag limit
Build your own (this post)$0 (infra only)< 1ms (in-process)Unlimited
LaunchDarkly (Starter)$12/seat< 1ms (SDK)Unlimited
LaunchDarkly (Enterprise)Custom< 1msUnlimited
Unleash (self-hosted)$0 (infra only)< 1msUnlimited
Flagsmith (Cloud)$45/mo< 10ms (API)Unlimited

See Also


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.

Talk to our team โ†’ | See our web development services โ†’

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

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.