Control feature rollouts, A/B tests, and kill switches with Redis. Instant updates, no deploys needed.
Simple on/off flags stored as hash fields:
import Redis from 'ioredis'; const redis = new Redis(process.env.ARCTICKEY_URL); class FeatureFlags { private prefix = 'features'; // Check if a feature is enabled async isEnabled(feature: string): Promise<boolean> { const value = await redis.hget(this.prefix, feature); return value === 'true' || value === '1'; } // Enable a feature async enable(feature: string) { await redis.hset(this.prefix, feature, 'true'); } // Disable a feature async disable(feature: string) { await redis.hset(this.prefix, feature, 'false'); } // Get all flags async getAll(): Promise<Record<string, boolean>> { const flags = await redis.hgetall(this.prefix); return Object.fromEntries( Object.entries(flags).map(([k, v]) => [k, v === 'true']) ); } } // Usage const flags = new FeatureFlags(); if (await flags.isEnabled('new_checkout')) { // Show new checkout flow } else { // Show old checkout flow }
Gradually roll out features to a percentage of users:
interface RolloutConfig { enabled: boolean; percentage: number; // 0-100 } class GradualRollout { async getConfig(feature: string): Promise<RolloutConfig> { const data = await redis.hgetall(`rollout:${feature}`); return { enabled: data.enabled === 'true', percentage: parseInt(data.percentage || '0'), }; } async setRollout(feature: string, percentage: number) { await redis.hset(`rollout:${feature}`, { enabled: 'true', percentage: percentage.toString(), }); } // Deterministic check based on user ID async isEnabledForUser(feature: string, userId: string): Promise<boolean> { const config = await this.getConfig(feature); if (!config.enabled) return false; if (config.percentage >= 100) return true; if (config.percentage <= 0) return false; // Hash user ID to get consistent 0-99 value const hash = this.hashUserId(userId); return hash < config.percentage; } private hashUserId(userId: string): number { let hash = 0; for (let i = 0; i < userId.length; i++) { hash = ((hash << 5) - hash) + userId.charCodeAt(i); hash = hash & hash; // Convert to 32bit integer } return Math.abs(hash) % 100; } } // Usage const rollout = new GradualRollout(); // Roll out to 10% of users await rollout.setRollout('new_algorithm', 10); // Check for specific user if (await rollout.isEnabledForUser('new_algorithm', user.id)) { // Use new algorithm } // Increase to 50% await rollout.setRollout('new_algorithm', 50);
Enable features for specific users or groups:
interface TargetedFlag { enabled: boolean; percentage: number; allowedUsers: string[]; allowedGroups: string[]; blockedUsers: string[]; } class TargetedFeatureFlags { async setFlag(feature: string, config: Partial<TargetedFlag>) { const key = `flag:${feature}`; if (config.allowedUsers) { await redis.sadd(`${key}:users`, ...config.allowedUsers); } if (config.allowedGroups) { await redis.sadd(`${key}:groups`, ...config.allowedGroups); } if (config.blockedUsers) { await redis.sadd(`${key}:blocked`, ...config.blockedUsers); } await redis.hset(key, { enabled: String(config.enabled ?? true), percentage: String(config.percentage ?? 0), }); } async isEnabledFor( feature: string, userId: string, userGroups: string[] = [] ): Promise<boolean> { const key = `flag:${feature}`; // Check if user is blocked if (await redis.sismember(`${key}:blocked`, userId)) { return false; } // Check if user is in allowlist if (await redis.sismember(`${key}:users`, userId)) { return true; } // Check if any of user's groups are allowed for (const group of userGroups) { if (await redis.sismember(`${key}:groups`, group)) { return true; } } // Fall back to percentage rollout const config = await redis.hgetall(key); if (config.enabled !== 'true') return false; const percentage = parseInt(config.percentage || '0'); const hash = this.hashUserId(userId); return hash < percentage; } private hashUserId(userId: string): number { let hash = 0; for (let i = 0; i < userId.length; i++) { hash = ((hash << 5) - hash) + userId.charCodeAt(i); } return Math.abs(hash) % 100; } } // Usage const flags = new TargetedFeatureFlags(); // Enable for beta testers and staff await flags.setFlag('experimental_ui', { enabled: true, percentage: 0, // Not for general rollout yet allowedGroups: ['beta_testers', 'staff'], allowedUsers: ['user_123'], // Specific VIP user }); // Check const canSee = await flags.isEnabledFor( 'experimental_ui', user.id, user.groups // ['beta_testers'] );
Emergency feature disable with instant propagation:
class KillSwitch { private key = 'killswitch'; async kill(feature: string, reason?: string) { await redis.hset(this.key, feature, JSON.stringify({ killed: true, reason, killedAt: new Date().toISOString(), })); // Notify all instances via pub/sub await redis.publish('killswitch', JSON.stringify({ action: 'kill', feature, reason, })); } async revive(feature: string) { await redis.hdel(this.key, feature); await redis.publish('killswitch', JSON.stringify({ action: 'revive', feature, })); } async isKilled(feature: string): Promise<boolean> { const data = await redis.hget(this.key, feature); if (!data) return false; return JSON.parse(data).killed === true; } } // In your app startup, subscribe to kill switch updates const subscriber = new Redis(process.env.ARCTICKEY_URL); subscriber.subscribe('killswitch'); const localKillCache = new Set<string>(); subscriber.on('message', (channel, message) => { const { action, feature } = JSON.parse(message); if (action === 'kill') { localKillCache.add(feature); console.log(`🚨 Feature ${feature} killed!`); } else if (action === 'revive') { localKillCache.delete(feature); console.log(`✅ Feature ${feature} revived`); } }); // Fast local check (no Redis call) function isLocallyKilled(feature: string): boolean { return localKillCache.has(feature); }
Assign users to experiment variants:
interface Experiment { name: string; variants: string[]; weights: number[]; // Must sum to 100 } class ABTesting { async createExperiment(experiment: Experiment) { await redis.hset(`experiment:${experiment.name}`, { variants: JSON.stringify(experiment.variants), weights: JSON.stringify(experiment.weights), active: 'true', }); } async getVariant(experimentName: string, userId: string): Promise<string | null> { const data = await redis.hgetall(`experiment:${experimentName}`); if (data.active !== 'true') return null; // Check if user already assigned const existing = await redis.hget(`exp_assignments:${experimentName}`, userId); if (existing) return existing; // Assign based on weights const variants = JSON.parse(data.variants) as string[]; const weights = JSON.parse(data.weights) as number[]; const hash = this.hashUserId(userId); let cumulative = 0; for (let i = 0; i < variants.length; i++) { cumulative += weights[i]; if (hash < cumulative) { // Store assignment for consistency await redis.hset(`exp_assignments:${experimentName}`, userId, variants[i]); return variants[i]; } } return variants[variants.length - 1]; } async trackConversion(experimentName: string, userId: string) { const variant = await redis.hget(`exp_assignments:${experimentName}`, userId); if (variant) { await redis.hincrby(`exp_conversions:${experimentName}`, variant, 1); } } async getResults(experimentName: string) { const [assignments, conversions] = await Promise.all([ redis.hgetall(`exp_assignments:${experimentName}`), redis.hgetall(`exp_conversions:${experimentName}`), ]); // Count assignments per variant const counts: Record<string, number> = {}; Object.values(assignments).forEach(variant => { counts[variant] = (counts[variant] || 0) + 1; }); return Object.entries(counts).map(([variant, total]) => ({ variant, total, conversions: parseInt(conversions[variant] || '0'), rate: total > 0 ? parseInt(conversions[variant] || '0') / total : 0, })); } private hashUserId(userId: string): number { let hash = 0; for (let i = 0; i < userId.length; i++) { hash = ((hash << 5) - hash) + userId.charCodeAt(i); } return Math.abs(hash) % 100; } } // Usage const ab = new ABTesting(); await ab.createExperiment({ name: 'checkout_button_color', variants: ['control', 'green', 'orange'], weights: [34, 33, 33], }); const variant = await ab.getVariant('checkout_button_color', user.id); // Returns: 'control', 'green', or 'orange' // On conversion await ab.trackConversion('checkout_button_color', user.id); // Get results const results = await ab.getResults('checkout_button_color'); // [{ variant: 'green', total: 1000, conversions: 45, rate: 0.045 }, ...]
import { Request, Response, NextFunction } from 'express'; declare global { namespace Express { interface Request { features: Record<string, boolean>; } } } function featureFlagsMiddleware(flags: FeatureFlags) { return async (req: Request, res: Response, next: NextFunction) => { // Load all flags once per request req.features = await flags.getAll(); next(); }; } // Usage app.use(featureFlagsMiddleware(flags)); app.get('/checkout', (req, res) => { if (req.features.new_checkout) { return res.render('checkout-v2'); } return res.render('checkout'); });
// hooks/useFeatureFlag.ts import { useQuery } from '@tanstack/react-query'; export function useFeatureFlag(flag: string) { const { data, isLoading } = useQuery({ queryKey: ['feature-flag', flag], queryFn: async () => { const res = await fetch(`/api/features/${flag}`); return res.json(); }, staleTime: 30 * 1000, // Cache for 30 seconds }); return { enabled: data?.enabled ?? false, loading: isLoading, }; } // Usage in component function CheckoutButton() { const { enabled: useNewCheckout, loading } = useFeatureFlag('new_checkout'); if (loading) return <ButtonSkeleton />; return useNewCheckout ? <NewCheckoutButton /> : <OldCheckoutButton />; }
Feature Flags
Control feature rollouts, A/B tests, and kill switches with Redis. Instant updates, no deploys needed.
Why Redis for Feature Flags?#
Basic Feature Flags#
Simple on/off flags stored as hash fields:
import Redis from 'ioredis'; const redis = new Redis(process.env.ARCTICKEY_URL); class FeatureFlags { private prefix = 'features'; // Check if a feature is enabled async isEnabled(feature: string): Promise<boolean> { const value = await redis.hget(this.prefix, feature); return value === 'true' || value === '1'; } // Enable a feature async enable(feature: string) { await redis.hset(this.prefix, feature, 'true'); } // Disable a feature async disable(feature: string) { await redis.hset(this.prefix, feature, 'false'); } // Get all flags async getAll(): Promise<Record<string, boolean>> { const flags = await redis.hgetall(this.prefix); return Object.fromEntries( Object.entries(flags).map(([k, v]) => [k, v === 'true']) ); } } // Usage const flags = new FeatureFlags(); if (await flags.isEnabled('new_checkout')) { // Show new checkout flow } else { // Show old checkout flow }Percentage Rollouts#
Gradually roll out features to a percentage of users:
interface RolloutConfig { enabled: boolean; percentage: number; // 0-100 } class GradualRollout { async getConfig(feature: string): Promise<RolloutConfig> { const data = await redis.hgetall(`rollout:${feature}`); return { enabled: data.enabled === 'true', percentage: parseInt(data.percentage || '0'), }; } async setRollout(feature: string, percentage: number) { await redis.hset(`rollout:${feature}`, { enabled: 'true', percentage: percentage.toString(), }); } // Deterministic check based on user ID async isEnabledForUser(feature: string, userId: string): Promise<boolean> { const config = await this.getConfig(feature); if (!config.enabled) return false; if (config.percentage >= 100) return true; if (config.percentage <= 0) return false; // Hash user ID to get consistent 0-99 value const hash = this.hashUserId(userId); return hash < config.percentage; } private hashUserId(userId: string): number { let hash = 0; for (let i = 0; i < userId.length; i++) { hash = ((hash << 5) - hash) + userId.charCodeAt(i); hash = hash & hash; // Convert to 32bit integer } return Math.abs(hash) % 100; } } // Usage const rollout = new GradualRollout(); // Roll out to 10% of users await rollout.setRollout('new_algorithm', 10); // Check for specific user if (await rollout.isEnabledForUser('new_algorithm', user.id)) { // Use new algorithm } // Increase to 50% await rollout.setRollout('new_algorithm', 50);User & Group Targeting#
Enable features for specific users or groups:
interface TargetedFlag { enabled: boolean; percentage: number; allowedUsers: string[]; allowedGroups: string[]; blockedUsers: string[]; } class TargetedFeatureFlags { async setFlag(feature: string, config: Partial<TargetedFlag>) { const key = `flag:${feature}`; if (config.allowedUsers) { await redis.sadd(`${key}:users`, ...config.allowedUsers); } if (config.allowedGroups) { await redis.sadd(`${key}:groups`, ...config.allowedGroups); } if (config.blockedUsers) { await redis.sadd(`${key}:blocked`, ...config.blockedUsers); } await redis.hset(key, { enabled: String(config.enabled ?? true), percentage: String(config.percentage ?? 0), }); } async isEnabledFor( feature: string, userId: string, userGroups: string[] = [] ): Promise<boolean> { const key = `flag:${feature}`; // Check if user is blocked if (await redis.sismember(`${key}:blocked`, userId)) { return false; } // Check if user is in allowlist if (await redis.sismember(`${key}:users`, userId)) { return true; } // Check if any of user's groups are allowed for (const group of userGroups) { if (await redis.sismember(`${key}:groups`, group)) { return true; } } // Fall back to percentage rollout const config = await redis.hgetall(key); if (config.enabled !== 'true') return false; const percentage = parseInt(config.percentage || '0'); const hash = this.hashUserId(userId); return hash < percentage; } private hashUserId(userId: string): number { let hash = 0; for (let i = 0; i < userId.length; i++) { hash = ((hash << 5) - hash) + userId.charCodeAt(i); } return Math.abs(hash) % 100; } } // Usage const flags = new TargetedFeatureFlags(); // Enable for beta testers and staff await flags.setFlag('experimental_ui', { enabled: true, percentage: 0, // Not for general rollout yet allowedGroups: ['beta_testers', 'staff'], allowedUsers: ['user_123'], // Specific VIP user }); // Check const canSee = await flags.isEnabledFor( 'experimental_ui', user.id, user.groups // ['beta_testers'] );Kill Switch#
Emergency feature disable with instant propagation:
class KillSwitch { private key = 'killswitch'; async kill(feature: string, reason?: string) { await redis.hset(this.key, feature, JSON.stringify({ killed: true, reason, killedAt: new Date().toISOString(), })); // Notify all instances via pub/sub await redis.publish('killswitch', JSON.stringify({ action: 'kill', feature, reason, })); } async revive(feature: string) { await redis.hdel(this.key, feature); await redis.publish('killswitch', JSON.stringify({ action: 'revive', feature, })); } async isKilled(feature: string): Promise<boolean> { const data = await redis.hget(this.key, feature); if (!data) return false; return JSON.parse(data).killed === true; } } // In your app startup, subscribe to kill switch updates const subscriber = new Redis(process.env.ARCTICKEY_URL); subscriber.subscribe('killswitch'); const localKillCache = new Set<string>(); subscriber.on('message', (channel, message) => { const { action, feature } = JSON.parse(message); if (action === 'kill') { localKillCache.add(feature); console.log(`🚨 Feature ${feature} killed!`); } else if (action === 'revive') { localKillCache.delete(feature); console.log(`✅ Feature ${feature} revived`); } }); // Fast local check (no Redis call) function isLocallyKilled(feature: string): boolean { return localKillCache.has(feature); }A/B Testing#
Assign users to experiment variants:
interface Experiment { name: string; variants: string[]; weights: number[]; // Must sum to 100 } class ABTesting { async createExperiment(experiment: Experiment) { await redis.hset(`experiment:${experiment.name}`, { variants: JSON.stringify(experiment.variants), weights: JSON.stringify(experiment.weights), active: 'true', }); } async getVariant(experimentName: string, userId: string): Promise<string | null> { const data = await redis.hgetall(`experiment:${experimentName}`); if (data.active !== 'true') return null; // Check if user already assigned const existing = await redis.hget(`exp_assignments:${experimentName}`, userId); if (existing) return existing; // Assign based on weights const variants = JSON.parse(data.variants) as string[]; const weights = JSON.parse(data.weights) as number[]; const hash = this.hashUserId(userId); let cumulative = 0; for (let i = 0; i < variants.length; i++) { cumulative += weights[i]; if (hash < cumulative) { // Store assignment for consistency await redis.hset(`exp_assignments:${experimentName}`, userId, variants[i]); return variants[i]; } } return variants[variants.length - 1]; } async trackConversion(experimentName: string, userId: string) { const variant = await redis.hget(`exp_assignments:${experimentName}`, userId); if (variant) { await redis.hincrby(`exp_conversions:${experimentName}`, variant, 1); } } async getResults(experimentName: string) { const [assignments, conversions] = await Promise.all([ redis.hgetall(`exp_assignments:${experimentName}`), redis.hgetall(`exp_conversions:${experimentName}`), ]); // Count assignments per variant const counts: Record<string, number> = {}; Object.values(assignments).forEach(variant => { counts[variant] = (counts[variant] || 0) + 1; }); return Object.entries(counts).map(([variant, total]) => ({ variant, total, conversions: parseInt(conversions[variant] || '0'), rate: total > 0 ? parseInt(conversions[variant] || '0') / total : 0, })); } private hashUserId(userId: string): number { let hash = 0; for (let i = 0; i < userId.length; i++) { hash = ((hash << 5) - hash) + userId.charCodeAt(i); } return Math.abs(hash) % 100; } } // Usage const ab = new ABTesting(); await ab.createExperiment({ name: 'checkout_button_color', variants: ['control', 'green', 'orange'], weights: [34, 33, 33], }); const variant = await ab.getVariant('checkout_button_color', user.id); // Returns: 'control', 'green', or 'orange' // On conversion await ab.trackConversion('checkout_button_color', user.id); // Get results const results = await ab.getResults('checkout_button_color'); // [{ variant: 'green', total: 1000, conversions: 45, rate: 0.045 }, ...]Express Middleware#
import { Request, Response, NextFunction } from 'express'; declare global { namespace Express { interface Request { features: Record<string, boolean>; } } } function featureFlagsMiddleware(flags: FeatureFlags) { return async (req: Request, res: Response, next: NextFunction) => { // Load all flags once per request req.features = await flags.getAll(); next(); }; } // Usage app.use(featureFlagsMiddleware(flags)); app.get('/checkout', (req, res) => { if (req.features.new_checkout) { return res.render('checkout-v2'); } return res.render('checkout'); });React Hook#
// hooks/useFeatureFlag.ts import { useQuery } from '@tanstack/react-query'; export function useFeatureFlag(flag: string) { const { data, isLoading } = useQuery({ queryKey: ['feature-flag', flag], queryFn: async () => { const res = await fetch(`/api/features/${flag}`); return res.json(); }, staleTime: 30 * 1000, // Cache for 30 seconds }); return { enabled: data?.enabled ?? false, loading: isLoading, }; } // Usage in component function CheckoutButton() { const { enabled: useNewCheckout, loading } = useFeatureFlag('new_checkout'); if (loading) return <ButtonSkeleton />; return useNewCheckout ? <NewCheckoutButton /> : <OldCheckoutButton />; }Best Practices#