Skip to content
Ayhan Sipahi Ayhan Sipahi

Feature Flags in React Native: A Stale-While-Revalidate Pattern

Why synchronous feature flag loading stalls checkout, and how a stale-while-revalidate cache serves React Native flags instantly with background refresh.

Feature flags sit on the critical path of a checkout flow, so how you load them is an availability decision. A synchronous API call to fetch a flag adds a network round trip to every render. Under cold-start conditions that round trip can stretch into seconds, the checkout times out, and the user abandons the cart. Stale-while-revalidate takes the lookup off the critical path: the cached value is returned instantly while a fresh value is fetched in the background. Treat the cache as the default answer and the network as a background refresh, never as a gate.

The Cost of Synchronous Flag Loading

A basic feature flag system makes a synchronous API call to AWS Parameter Store every time a flag value is needed:

// Synchronous flag loading: timeout risk
const getFeatureFlag = async (flagName) => {
  const response = await fetch(`/api/flags/${flagName}`);
  return response.json();
};

// Called in checkout flow
if (await getFeatureFlag('new-payment-processor')) {
  // Process with new system
}

Four failure modes follow from that shape:

  1. Cold starts: a Lambda cold start plus a Parameter Store round trip can push the response into seconds
  2. No caching: every checkout pays the full network cost again
  3. Cascading timeouts: one slow flag makes every screen that reads it slow
  4. No offline support: a dropped connection leaves the app with no flag values at all

Stale-While-Revalidate Benefits

Instead of “load flag, wait, hope it works,” the SWR pattern:

  1. Return cached data instantly (even if it’s stale)
  2. Fetch fresh data in background (revalidate)
  3. Update cache silently when new data arrives

The user experience change:

  • Before: checkout blocks on a network call before it can render
  • After: instant response from cache, silent update when fresh data lands
  • Offline: the app keeps working with last-known values

Production Architecture

The SWR-based system has four components:

  1. FeatureFlagCache: In-memory cache with AsyncStorage persistence
  2. useFeatureFlag: SWR-style hook with background revalidation
  3. Smart invalidation: Automatic updates on app focus/network changes
  4. AWS backend: Parameter Store + Lambda with proper caching

The first three keep the flag read local, so a render never waits on the network. The fourth keeps the origin cheap, so background revalidation does not turn into a Parameter Store bill.

React Component

useFeatureFlag Hook

FeatureFlagCache

AsyncStorage Persistence

AWS API Gateway

Lambda + Parameter Store

App Focus Event

Network Reconnect

Background Revalidation

Timeout Incident

Cache-First Design

Production Implementation

The four components fit together as one module, built up here piece by piece:

Cache Manager

// SWR-style feature flag cache manager
import { useState, useEffect, useRef, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppState } from 'react-native';
import NetInfo from '@react-native-community/netinfo';

class FeatureFlagCache {
  constructor() {
    this.cache = new Map();
    this.subscribers = new Map();
    this.revalidateOnFocus = true;
    this.revalidateOnReconnect = true;
    // Prevent request storms
    this.dedupingInterval = 2000;
    // Track metrics that matter
    this.stats = {
      hits: 0,
      misses: 0,
      revalidations: 0,
      failures: 0,
    };
    this.setupGlobalListeners();
  }

  setupGlobalListeners() {
    // Revalidate on iOS foreground transition
    AppState.addEventListener('change', (nextAppState) => {
      if (nextAppState === 'active' && this.revalidateOnFocus) {
        console.log('App focused, revalidating all flags');
        this.revalidateAll();
      }
    });

    // Revalidate on network reconnect
    NetInfo.addEventListener(state => {
      if (state.isConnected && this.revalidateOnReconnect) {
        console.log('Network reconnected, revalidating flags');
        this.revalidateAll();
      }
    });
  }

  getCacheKey(key) {
    return `feature_flags_${key}`;
  }

  getCache(key) {
    const cached = this.cache.get(key);
    if (cached) {
      this.stats.hits++;
      return cached;
    }
    this.stats.misses++;
    return null;
  }

  setCache(key, data) {
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      isValidating: false,
      // Track how many times this flag was served from cache
      servedCount: (this.cache.get(key)?.servedCount || 0) + 1
    });

    // Persist to AsyncStorage for offline support
    this.saveToStorage(key, data);
    this.notifySubscribers(key, data);
  }

  notifySubscribers(key, data) {
    const subscribers = this.subscribers.get(key) || new Set();
    subscribers.forEach(callback => callback(data));
  }

  subscribe(key, callback) {
    if (!this.subscribers.has(key)) {
      this.subscribers.set(key, new Set());
    }
    this.subscribers.get(key).add(callback);

    return () => {
      const subscribers = this.subscribers.get(key);
      if (subscribers) {
        subscribers.delete(callback);
        if (subscribers.size === 0) {
          this.subscribers.delete(key);
        }
      }
    };
  }

  async revalidateAll() {
    const startTime = Date.now();
    const keys = Array.from(this.cache.keys());

    console.log(`Revalidating ${keys.length} flags`);

    // Batch requests to avoid overwhelming the backend
    const promises = keys.map(key => this.revalidate(key));
    const results = await Promise.allSettled(promises);

    const successful = results.filter(r => r.status === 'fulfilled').length;
    const failed = results.length - successful;

    console.log(`Revalidation complete: ${successful} succeeded, ${failed} failed in ${Date.now() - startTime}ms`);

    // Report metrics for monitoring
    this.reportMetrics({
      revalidation_duration: Date.now() - startTime,
      successful_revalidations: successful,
      failed_revalidations: failed,
    });
  }

  async revalidate(key) {
    const cached = this.cache.get(key);
    if (cached && !cached.isValidating) {
      cached.isValidating = true;
      this.stats.revalidations++;

      try {
        const freshData = await this.fetcher(key);
        this.setCache(key, freshData);
        console.log(`Revalidated flag: ${key}`);
      } catch (error) {
        this.stats.failures++;
        console.error(`Revalidation failed for ${key}:`, error);

        // Don't break the app for flag failures
        if (cached) cached.isValidating = false;

        // Report to crash analytics
        this.reportError('revalidation_failed', error, { flag: key });
      }
    }
  }

  async fetcher(key) {
    // Cached endpoint replacing direct Parameter Store calls
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout

    try {
      const response = await fetch(
        `https://api.yourapp.com/v2/feature-flags/${key}`,
        {
          headers: {
            'X-API-Version': '2.0',
            'X-Request-ID': `${Date.now()}-${Math.random().toString(36)}`,
            // Include device info for targeted rollouts
            'X-Device-ID': await this.getDeviceId(),
            'User-Agent': 'YourApp/3.2.1 (React Native)',
          },
          signal: controller.signal,
        }
      );

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      const data = await response.json();

      // Validate response structure
      if (!data || typeof data.enabled === 'undefined') {
        throw new Error('Invalid flag response format');
      }

      return data;
    } finally {
      clearTimeout(timeoutId);
    }
  }

  async loadFromStorage(key) {
    try {
      const stored = await AsyncStorage.getItem(this.getCacheKey(key));
      if (!stored) return null;

      const parsed = JSON.parse(stored);

      // Don't use storage data older than 7 days
      const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
      if (Date.now() - parsed.timestamp > maxAge) {
        console.log(`Discarding stale storage data for ${key}`);
        return null;
      }

      return parsed;
    } catch (error) {
      console.error('Failed to load from storage:', error);
      return null;
    }
  }

  async saveToStorage(key, data) {
    try {
      const payload = {
        ...data,
        timestamp: Date.now(),
        version: '2.0', // Track storage format version
      };

      await AsyncStorage.setItem(
        this.getCacheKey(key),
        JSON.stringify(payload)
      );
    } catch (error) {
      console.error('Failed to save to storage:', error);
      // Don't fail flag operations for storage errors
    }
  }

  // Helper method for analytics
  async getDeviceId() {
    // Implementation depends on your analytics setup
    return 'device-id-placeholder';
  }

  reportMetrics(metrics) {
    // Send to your analytics service
    console.log('Feature flag metrics:', metrics);
  }

  reportError(event, error, context) {
    // Send to crash reporting service
    console.error('Feature flag error:', event, error, context);
  }
}

// Singleton instance - multiple instances cause chaos
const flagCache = new FeatureFlagCache();

React Hook

// SWR-style flag hook
export function useFeatureFlag(flagName, options = {}) {
  const {
    refreshInterval = 0,
    revalidateOnMount = true,
    fallbackData = null,  // Critical: always provide fallback
    onSuccess,
    onError,
    // Additional options
    staleTime = 5 * 60 * 1000,  // 5 minutes
    dedupingInterval = 2000,
    errorRetryCount = 3,
  } = options;

  const [data, setData] = useState(fallbackData);
  const [error, setError] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [isValidating, setIsValidating] = useState(false);
  // Track how many times this hook served from cache
  const [cacheHits, setCacheHits] = useState(0);

  const intervalRef = useRef();
  const mountedRef = useRef(true);
  const retryCountRef = useRef(0);
  const lastFetchRef = useRef(0);

  const fetcher = useCallback(async (key) => {
    // Prevent request spam
    const now = Date.now();
    if (now - lastFetchRef.current < dedupingInterval) {
      console.log(`Deduping request for ${key}`);
      return null;
    }
    lastFetchRef.current = now;

    try {
      setIsValidating(true);
      setError(null);

      const result = await flagCache.fetcher(key);

      if (mountedRef.current) {
        setData(result);
        setError(null);
        flagCache.setCache(key, result);
        retryCountRef.current = 0;  // Reset retry count on success
        onSuccess?.(result);

        // Log successful flag fetch for debugging
        console.log(`Flag ${key} fetched:`, result);
      }

      return result;
    } catch (err) {
      console.error(`Flag fetch failed for ${key}:`, err);

      if (mountedRef.current) {
        // Only set error if we've exhausted retries
        if (retryCountRef.current >= errorRetryCount) {
          setError(err);
          onError?.(err);
        } else {
          // Retry with exponential backoff
          retryCountRef.current++;
          const delay = Math.pow(2, retryCountRef.current) * 1000;
          setTimeout(() => {
            if (mountedRef.current) {
              fetcher(key);
            }
          }, delay);
        }
      }
      throw err;
    } finally {
      if (mountedRef.current) {
        setIsLoading(false);
        setIsValidating(false);
      }
    }
  }, [onSuccess, onError, dedupingInterval, errorRetryCount]);

  // Optimistic updates
  const mutate = useCallback(async (newData, shouldRevalidate = true) => {
    console.log(`Manual mutation for ${flagName}:`, newData);

    if (typeof newData === 'function') {
      setData(prev => {
        const updated = newData(prev);
        flagCache.setCache(flagName, updated);
        return updated;
      });
    } else if (newData !== undefined) {
      setData(newData);
      flagCache.setCache(flagName, newData);

      // Analytics: track manual flag overrides
      flagCache.reportMetrics({
        flag_manual_override: flagName,
        old_value: data,
        new_value: newData,
      });
    }

    if (shouldRevalidate) {
      return fetcher(flagName);
    }
  }, [flagName, fetcher, data]);

  useEffect(() => {
    mountedRef.current = true;

    const loadData = async () => {
      const startTime = Date.now();

      // Step 1: Check in-memory cache first (fastest)
      const cached = flagCache.getCache(flagName);
      if (cached) {
        setData(cached.data);
        setIsLoading(false);
        setCacheHits(prev => prev + 1);

        console.log(`Cache hit for ${flagName}: ${Date.now() - startTime}ms`);

        // Background revalidation if stale
        const isStale = Date.now() - cached.timestamp > staleTime;
        if (isStale && !cached.isValidating) {
          console.log(`Flag ${flagName} is stale, revalidating in background`);
          flagCache.revalidate(flagName);
        }
        return;
      }

      // Step 2: Load from AsyncStorage (slower but offline-capable)
      const stored = await flagCache.loadFromStorage(flagName);
      if (stored) {
        setData(stored.data || stored);  // Handle different storage formats
        setIsLoading(false);

        console.log(`Storage hit for ${flagName}: ${Date.now() - startTime}ms`);

        // Always revalidate stored data (could be outdated)
        if (revalidateOnMount) {
          flagCache.revalidate(flagName);
        }
        return;
      }

      // Step 3: Fresh fetch (slowest, only if no cached data)
      if (revalidateOnMount) {
        console.log(`No cached data for ${flagName}, fetching fresh`);
        try {
          await fetcher(flagName);
        } catch (error) {
          // Use fallback if all else fails
          if (!data && fallbackData !== null) {
            console.log(`Using fallback for ${flagName}:`, fallbackData);
            setData(fallbackData);
          }
        }
      } else {
        setIsLoading(false);
      }
    };

    loadData();

    // Subscribe to cache updates
    const unsubscribe = flagCache.subscribe(flagName, (newData) => {
      if (mountedRef.current) {
        console.log(`Cache update for ${flagName}:`, newData);
        setData(newData);
      }
    });

    // Setup polling interval (use sparingly)
    if (refreshInterval > 0) {
      intervalRef.current = setInterval(() => {
        if (mountedRef.current) {
          console.log(`Interval revalidation for ${flagName}`);
          flagCache.revalidate(flagName);
        }
      }, refreshInterval);
    }

    return () => {
      mountedRef.current = false;
      unsubscribe();
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }

      // Log usage stats on unmount (helpful for optimization)
      console.log(`Flag ${flagName} unmounted. Cache hits: ${cacheHits}`);
    };
  }, [flagName, fetcher, refreshInterval, revalidateOnMount, staleTime, cacheHits]);

  // The return object that makes checkout flows work
  return {
    data,
    error,
    isLoading,
    isValidating,
    mutate,
    // Extra metadata for debugging and optimization
    cacheHits,
    lastUpdated: flagCache.getCache(flagName)?.timestamp,
    // Debug helper methods
    refresh: () => flagCache.revalidate(flagName),
    clearCache: () => {
      flagCache.cache.delete(flagName);
      AsyncStorage.removeItem(flagCache.getCacheKey(flagName));
    },
  };
}

Batch Hook: Multiple Flags

One constraint before the code: calling useFeatureFlag inside a loop is only legal while flagNames has the same length and order on every render. Pass a module-level constant array, never one built inline, or the hook order shifts and React throws.

// Handles multiple flags efficiently
export function useFeatureFlags(flagNames, options = {}) {
  const flags = {};
  const errors = {};
  const isLoading = {};
  const isValidating = {};
  const mutators = {};
  const cacheStats = {};

  // Individual hooks for each flag
  flagNames.forEach(flagName => {
    const result = useFeatureFlag(flagName, options);
    flags[flagName] = result.data;
    errors[flagName] = result.error;
    isLoading[flagName] = result.isLoading;
    isValidating[flagName] = result.isValidating;
    mutators[flagName] = result.mutate;
    cacheStats[flagName] = {
      hits: result.cacheHits,
      lastUpdated: result.lastUpdated,
    };
  });

  const isAnyLoading = Object.values(isLoading).some(Boolean);
  const isAnyValidating = Object.values(isValidating).some(Boolean);
  const hasErrors = Object.values(errors).some(Boolean);
  const totalCacheHits = Object.values(cacheStats).reduce(
    (sum, stats) => sum + (stats.hits || 0), 0
  );

  // Batch operations for performance
  const refreshAll = useCallback(() => {
    console.log(`Refreshing ${flagNames.length} flags`);
    flagNames.forEach(name => {
      flagCache.revalidate(name);
    });
  }, [flagNames]);

  const clearAllCaches = useCallback(() => {
    console.log(`Clearing cache for ${flagNames.length} flags`);
    flagNames.forEach(name => {
      flagCache.cache.delete(name);
      AsyncStorage.removeItem(flagCache.getCacheKey(name));
    });
  }, [flagNames]);

  return {
    flags,
    errors,
    isLoading: isAnyLoading,
    isValidating: isAnyValidating,
    hasErrors,
    mutate: mutators,
    // Batch operations
    refreshAll,
    clearAllCaches,
    // Performance stats
    totalCacheHits,
    cacheStats,
  };
}

AWS Backend Architecture

Reading Parameter Store directly from the client is the bottleneck. A thin Lambda in front of it adds a second cache layer, so repeat reads never reach SSM and the client cache has a fast origin to revalidate against:

Production Lambda

// Cached feature flag Lambda
const { SSMClient, GetParameterCommand } = require('@aws-sdk/client-ssm');
const { CloudWatchClient, PutMetricDataCommand } = require('@aws-sdk/client-cloudwatch');

// Reuse connections - cost optimization
const ssm = new SSMClient({
  region: process.env.AWS_REGION,
  maxAttempts: 3,
  requestHandler: {
    connectionTimeout: 1000,
    socketTimeout: 1000,
  },
});

const cloudwatch = new CloudWatchClient({ region: process.env.AWS_REGION });

// In-memory cache in front of Parameter Store
const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes

exports.handler = async (event) => {
    const startTime = Date.now();
    const { flagName } = event.pathParameters;
    const deviceId = event.headers['X-Device-ID'] || 'unknown';
    const requestId = event.requestContext.requestId;

    console.log(`Flag request: ${flagName}`, { deviceId, requestId });

    try {
        // Check cache first
        const cacheKey = `/feature-flags/${flagName}`;
        const cached = cache.get(cacheKey);

        if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
            console.log(`Cache hit for ${flagName}`);

            // Track cache performance
            await recordMetric('CacheHits', 1, flagName);

            return createResponse(200, cached.data, {
                'X-Cache': 'HIT',
                'X-Response-Time': `${Date.now() - startTime}ms`,
            });
        }

        // Fetch from Parameter Store
        const command = new GetParameterCommand({
            Name: cacheKey,
            WithDecryption: true,  // Support encrypted flags
        });

        const result = await ssm.send(command);

        if (!result.Parameter) {
            await recordMetric('FlagNotFound', 1, flagName);
            return createResponse(404, {
                error: 'Flag not found',
                flag: flagName,
                timestamp: new Date().toISOString(),
            });
        }

        let flagData;
        try {
            flagData = JSON.parse(result.Parameter.Value);
        } catch (parseError) {
            // Handle non-JSON values (backwards compatibility)
            flagData = {
                enabled: result.Parameter.Value === 'true',
                value: result.Parameter.Value,
            };
        }

        // Enhance flag data with metadata
        const enhancedData = {
            ...flagData,
            flag_name: flagName,
            last_modified: result.Parameter.LastModifiedDate,
            version: result.Parameter.Version,
            // Support for targeted rollouts
            user_targeting: await checkUserTargeting(flagData, deviceId),
        };

        // Cache the result
        cache.set(cacheKey, {
            data: enhancedData,
            timestamp: Date.now(),
        });

        // Clean up old cache entries
        if (cache.size > 1000) {
            const oldestKey = cache.keys().next().value;
            cache.delete(oldestKey);
        }

        await recordMetric('CacheMisses', 1, flagName);
        await recordMetric('ResponseTime', Date.now() - startTime, flagName);

        return createResponse(200, enhancedData, {
            'X-Cache': 'MISS',
            'X-Response-Time': `${Date.now() - startTime}ms`,
            'Cache-Control': 'private, max-age=300',  // 5 minute client cache
        });

    } catch (error) {
        console.error('Lambda error:', {
            error: error.message,
            stack: error.stack,
            flagName,
            requestId,
        });

        await recordMetric('Errors', 1, flagName);

        return createResponse(500, {
            error: 'Internal server error',
            request_id: requestId,
            timestamp: new Date().toISOString(),
        });
    }
};

function createResponse(statusCode, body, additionalHeaders = {}) {
    return {
        statusCode,
        headers: {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Device-ID',
            'X-API-Version': '2.0',
            ...additionalHeaders,
        },
        body: JSON.stringify(body),
    };
}

// User targeting for gradual rollouts
async function checkUserTargeting(flagData, deviceId) {
    if (!flagData.rollout_percentage) return true;

    // Consistent hash-based rollout
    const hash = require('crypto')
        .createHash('md5')
        .update(deviceId + flagData.flag_name)
        .digest('hex');

    const userPercentile = parseInt(hash.substr(0, 2), 16) % 100;
    return userPercentile < flagData.rollout_percentage;
}

// CloudWatch metrics for monitoring
async function recordMetric(metricName, value, flagName) {
    try {
        await cloudwatch.send(new PutMetricDataCommand({
            Namespace: 'FeatureFlags/Lambda',
            MetricData: [{
                MetricName: metricName,
                Value: value,
                Unit: metricName === 'ResponseTime' ? 'Milliseconds' : 'Count',
                Dimensions: [{
                    Name: 'FlagName',
                    Value: flagName,
                }],
                Timestamp: new Date(),
            }],
        }));
    } catch (error) {
        console.error('Failed to record metric:', error);
        // Don't fail the request for metrics errors
    }
}

Production Parameter Store Setup

# Production flag structure
aws ssm put-parameter \
  --name "/feature-flags/payment-processor-v2" \
  --value '{
    "enabled": true,
    "rollout_percentage": 85,
    "created_at": "2023-05-30T10:00:00Z",
    "created_by": "payment-team",
    "description": "New Stripe payment processor with 3DS2 support",
    "kill_switch": false,
    "environments": ["production"],
    "monitoring": {
      "error_threshold": 0.05,
      "latency_threshold_ms": 2000
    }
  }' \
  --type "SecureString" \
  --description "Critical payment flag - DO NOT DELETE"

# Timeout tolerance flag
aws ssm put-parameter \
  --name "/feature-flags/checkout-timeout-extended" \
  --value '{
    "enabled": true,
    "timeout_ms": 30000,
    "fallback_enabled": true,
    "emergency_override": false,
    "purpose": "checkout-timeout-tolerance"
  }' \
  --type "SecureString"

# Feature flags with gradual rollout
aws ssm put-parameter \
  --name "/feature-flags/new-checkout-ui" \
  --value '{
    "enabled": true,
    "rollout_percentage": 10,
    "target_segments": ["beta-users", "premium"],
    "a_b_test": {
      "experiment_id": "checkout-ui-v2",
      "variant": "treatment"
    },
    "metrics_to_watch": [
      "checkout_conversion_rate",
      "checkout_abandonment_rate",
      "payment_success_rate"
    ]
  }' \
  --type "SecureString"

Production Usage Examples

Checkout Component

// SWR-style checkout component
import React from 'react';
import { View, Text, Button, Alert } from 'react-native';
import { useFeatureFlag } from './hooks/useFeatureFlag';

function CheckoutFlow() {
  const {
    data: paymentConfig,
    error,
    isLoading,
    isValidating,
    mutate,
    cacheHits,
  } = useFeatureFlag('payment-processor-v2', {
    // Critical: always provide a safe fallback
    fallbackData: {
      enabled: false,  // Safe default
      processor: 'legacy',
      timeout_ms: 15000,
    },
    staleTime: 2 * 60 * 1000,  // 2 minutes (frequent payments need fresh data)
    onSuccess: (data) => {
      console.log('Payment config updated:', data);
      // Track successful flag loads for analytics
      analytics.track('feature_flag_loaded', {
        flag: 'payment-processor-v2',
        value: data,
        cache_hits: cacheHits,
      });
    },
    onError: (error) => {
      console.error('Payment flag error:', error);
      // Alert on payment flag failures (critical for revenue)
      crashlytics().recordError(error);
    }
  });

  // Emergency kill switch for payment issues
  const handleEmergencyFallback = () => {
    Alert.alert(
      'Emergency Fallback',
      'Switch to legacy payment processor?',
      [
        { text: 'Cancel', style: 'cancel' },
        {
          text: 'Yes',
          onPress: () => {
            mutate({
              ...paymentConfig,
              enabled: false,
              emergency_override: true
            }, false);
            analytics.track('emergency_payment_fallback');
          }
        },
      ]
    );
  };

  // Never show loading for critical checkout flow
  const config = paymentConfig || {
    enabled: false,
    processor: 'legacy',
    timeout_ms: 15000,
  };

  return (
    <View>
      <Text>Payment Processor: {config.enabled ? 'v2 (Stripe)' : 'Legacy'}</Text>

      {/* Show system status without blocking UI */}
      {isValidating && (
        <Text style={{ color: 'gray', fontSize: 12 }}>
          Refreshing payment config in background...
        </Text>
      )}

      {error && (
        <View style={{ backgroundColor: '#fff3cd', padding: 8 }}>
          <Text style={{ color: '#856404' }}>
            Using cached payment settings (flag service unavailable)
          </Text>
        </View>
      )}

      <Button
        title="Emergency Fallback"
        onPress={handleEmergencyFallback}
        color="red"
      />

      {/* The actual payment component */}
      {config.enabled ? (
        <StripePaymentForm
          timeout={config.timeout_ms}
          onSuccess={() => analytics.track('payment_success', { processor: 'v2' })}
          onError={(err) => {
            // Auto-fallback on payment errors
            if (err.code === 'TIMEOUT') {
              mutate({ ...config, enabled: false }, false);
            }
          }}
        />
      ) : (
        <LegacyPaymentForm
          onSuccess={() => analytics.track('payment_success', { processor: 'legacy' })}
        />
      )}

      {/* Debug info for development */}
      {__DEV__ && (
        <Text style={{ fontSize: 10, color: 'gray' }}>
          Cache hits: {cacheHits} | Config: {JSON.stringify(config, null, 2)}
        </Text>
      )}
    </View>
  );
}

Dashboard with Multiple Flags

// Dashboard component reading multiple feature flags
function Dashboard() {
  const {
    flags,
    isLoading,
    hasErrors,
    mutate,
    refreshAll,
    totalCacheHits,
    cacheStats,
  } = useFeatureFlags([
    'new-checkout-ui',
    'payment-processor-v2',
    'dark-mode',
    'a-b-test-homepage',
    'premium-features',
    'mobile-push-notifications',
    'analytics-enhanced',
    'referral-program',
    'social-login',
    'advanced-search',
  ], {
    // No refresh interval - rely on SWR pattern
    staleTime: 10 * 60 * 1000,  // 10 minutes
    fallbackData: null,  // Let each flag handle its own fallback
  });

  // Never block dashboard rendering for flags

  return (
    <View style={{ flex: 1 }}>
      {/* Progressive enhancement - features appear when flags load */}

      {flags['new-checkout-ui'] && (
        <NewCheckoutBanner
          onDismiss={() => {
            // Temporarily disable for this user
            mutate['new-checkout-ui']({
              ...flags['new-checkout-ui'],
              user_dismissed: true
            }, false);
          }}
        />
      )}

      <ScrollView>
        {/* Core features always render */}
        <ProductList />

        {/* Enhanced features only when flags are ready */}
        {flags['premium-features'] && (
          <PremiumSection
            config={flags['premium-features']}
            onUpgrade={() => {
              analytics.track('premium_upgrade_clicked', {
                feature_flag_config: flags['premium-features']
              });
            }}
          />
        )}

        {flags['referral-program']?.enabled && (
          <ReferralWidget
            incentive={flags['referral-program'].incentive_amount}
            onShare={() => analytics.track('referral_shared')}
          />
        )}

        {/* A/B test component */}
        {flags['a-b-test-homepage'] && (
          <ABTestComponent
            variant={flags['a-b-test-homepage'].variant}
            experimentId={flags['a-b-test-homepage'].experiment_id}
            onConversion={(event) => {
              analytics.track('ab_test_conversion', {
                experiment_id: flags['a-b-test-homepage'].experiment_id,
                variant: flags['a-b-test-homepage'].variant,
                event_type: event,
              });
            }}
          />
        )}
      </ScrollView>

      {/* Debug panel for development */}
      {__DEV__ && (
        <View style={{ position: 'absolute', top: 50, right: 10, backgroundColor: 'rgba(0,0,0,0.8)', padding: 10 }}>
          <Text style={{ color: 'white', fontSize: 10 }}>Flag Cache Stats:</Text>
          <Text style={{ color: 'white', fontSize: 8 }}>Total hits: {totalCacheHits}</Text>
          <Text style={{ color: 'white', fontSize: 8 }}>Errors: {hasErrors ? 'YES' : 'NO'}</Text>
          <Button
            title="Refresh All"
            onPress={refreshAll}
            color="orange"
          />
          {Object.entries(cacheStats).map(([flag, stats]) => (
            <Text key={flag} style={{ color: 'gray', fontSize: 8 }}>
              {flag}: {stats.hits} hits
            </Text>
          ))}
        </View>
      )}
    </View>
  );
}

Complex A/B Testing with User Targeting

// A/B test wrapper with user targeting and kill-switch support
function ABTestWrapper({ children, userId, userSegment }) {
  const { data: experimentConfig, mutate, refresh } = useFeatureFlag(
    'homepage-conversion-experiment',
    {
      fallbackData: {
        enabled: false,
        experiment_id: 'homepage-v1',
        variants: {
          control: 50,
          treatment_a: 25,  // New CTA button
          treatment_b: 25,  // Simplified form
        },
        targeting: {
          min_account_age_days: 0,
          allowed_segments: ['free', 'trial', 'premium'],
          excluded_user_ids: [],
        },
        kill_switch: false,
      },
      staleTime: 30 * 60 * 1000, // 30 minutes for experiments
      onSuccess: (data) => {
        console.log('A/B test config loaded:', data.experiment_id);

        // Track experiment exposure
        analytics.track('experiment_config_loaded', {
          experiment_id: data.experiment_id,
          user_id: userId,
        });
      },
    }
  );

  // Determine user's variant with consistent hashing
  const userVariant = useMemo(() => {
    if (!experimentConfig?.enabled || experimentConfig.kill_switch) {
      return 'control';
    }

    // Check targeting criteria
    const targeting = experimentConfig.targeting;
    if (!targeting.allowed_segments.includes(userSegment)) {
      return 'control';
    }

    if (targeting.excluded_user_ids.includes(userId)) {
      return 'control';
    }

    // Consistent hash-based variant assignment (RN-safe: node:crypto is not available in React Native)
    const seed = userId + experimentConfig.experiment_id;
    let h = 0;
    for (let i = 0; i < seed.length; i++) {
      h = ((h << 5) - h + seed.charCodeAt(i)) | 0;
    }
    const userPercentile = Math.abs(h) % 100;

    const variants = experimentConfig.variants;
    let cumulativePercentage = 0;

    for (const [variant, percentage] of Object.entries(variants)) {
      cumulativePercentage += percentage;
      if (userPercentile < cumulativePercentage) {
        return variant;
      }
    }

    return 'control';  // Fallback
  }, [experimentConfig, userId, userSegment]);

  // Track experiment exposure once per session
  useEffect(() => {
    if (experimentConfig?.enabled && userVariant !== 'control') {
      analytics.track('experiment_exposed', {
        experiment_id: experimentConfig.experiment_id,
        variant: userVariant,
        user_id: userId,
        user_segment: userSegment,
      });
    }
  }, [experimentConfig?.experiment_id, userVariant, userId, userSegment]);

  // Manual refresh for testing
  const handleRefreshExperiment = () => {
    console.log('Refreshing A/B test config');
    refresh();
  };

  // Emergency kill switch
  const handleKillSwitch = () => {
    Alert.alert(
      'Kill Switch',
      'Disable this experiment for all users?',
      [
        { text: 'Cancel', style: 'cancel' },
        {
          text: 'Kill',
          style: 'destructive',
          onPress: () => {
            mutate({
              ...experimentConfig,
              kill_switch: true,
              killed_at: new Date().toISOString(),
              killed_by: userId,
            }, false);

            analytics.track('experiment_killed', {
              experiment_id: experimentConfig.experiment_id,
              killed_by: userId,
            });
          }
        },
      ]
    );
  };

  return (
    <View>
      {/* Render variant-specific content */}
      {React.cloneElement(children, {
        variant: userVariant,
        experimentId: experimentConfig?.experiment_id,
        onConversion: (eventType) => {
          analytics.track('conversion', {
            experiment_id: experimentConfig?.experiment_id,
            variant: userVariant,
            event_type: eventType,
            user_id: userId,
          });
        },
      })}

      {/* Admin controls for testing */}
      {__DEV__ && (
        <View style={{ position: 'absolute', bottom: 100, right: 10 }}>
          <Button title="Refresh Experiment" onPress={handleRefreshExperiment} />
          <Button title="Kill Switch" onPress={handleKillSwitch} color="red" />
          <Text style={{ fontSize: 10 }}>Variant: {userVariant}</Text>
        </View>
      )}
    </View>
  );
}

Performance Optimizations

Memory Management

// Cache cleanup: bounded size + periodic eviction prevents memory pressure
class FeatureFlagCache {
  constructor() {
    // ... existing code
    this.maxCacheSize = 200;  // Bounded to keep memory flat
    this.maxStorageAge = 7 * 24 * 60 * 60 * 1000;  // 7 days

    // Cleanup every 10 minutes to prevent unbounded growth
    this.cleanupInterval = setInterval(() => {
      this.cleanup();
    }, 10 * 60 * 1000);

    // Track memory usage for monitoring
    this.memoryStats = {
      cleanupRuns: 0,
      entriesDeleted: 0,
      lastCleanupTime: Date.now(),
    };
  }

  cleanup() {
    const startTime = Date.now();
    const initialSize = this.cache.size;

    // Step 1: Remove expired entries
    const now = Date.now();
    for (const [key, value] of this.cache.entries()) {
      if (now - value.timestamp > this.maxStorageAge) {
        this.cache.delete(key);
        console.log(`Deleted expired cache entry: ${key}`);
      }
    }

    // Step 2: LRU cleanup if still over limit
    if (this.cache.size > this.maxCacheSize) {
      const entries = Array.from(this.cache.entries());
      // Sort by last access time (LRU)
      const sorted = entries.sort((a, b) =>
        (a[1].lastAccessed || a[1].timestamp) - (b[1].lastAccessed || b[1].timestamp)
      );

      const toDelete = sorted.slice(0, entries.length - this.maxCacheSize);

      toDelete.forEach(([key]) => {
        this.cache.delete(key);
        console.log(`LRU deleted cache entry: ${key}`);
      });
    }

    // Update stats
    this.memoryStats.cleanupRuns++;
    this.memoryStats.entriesDeleted += initialSize - this.cache.size;
    this.memoryStats.lastCleanupTime = Date.now();

    console.log(`Cache cleanup: ${initialSize} -> ${this.cache.size} entries in ${Date.now() - startTime}ms`);

    // Report memory usage to analytics
    this.reportMetrics({
      cache_size: this.cache.size,
      cleanup_duration: Date.now() - startTime,
      memory_freed_mb: (initialSize - this.cache.size) * 0.001,  // Rough estimate
    });
  }

  // Track access for LRU
  getCache(key) {
    const cached = this.cache.get(key);
    if (cached) {
      cached.lastAccessed = Date.now();  // Update LRU timestamp
      this.stats.hits++;
      return cached;
    }
    this.stats.misses++;
    return null;
  }
}

Request Deduplication

// Request deduplication: coalesce concurrent fetches for the same key
class FeatureFlagCache {
  constructor() {
    // ... existing code
    this.pendingRequests = new Map();
    this.requestStats = {
      dedupedRequests: 0,
      concurrentRequestsPrevented: 0,
    };
  }

  async fetcher(key) {
    // Check if request is already in flight
    if (this.pendingRequests.has(key)) {
      console.log(`Deduping concurrent request for ${key}`);
      this.requestStats.dedupedRequests++;

      // Return the existing promise
      return this.pendingRequests.get(key);
    }

    // Create new request with timeout and retry logic
    const promise = this.makeRequestWithRetry(key, 3);
    this.pendingRequests.set(key, promise);

    try {
      const result = await promise;
      return result;
    } finally {
      // Always clean up pending request
      this.pendingRequests.delete(key);
    }
  }

  async makeRequestWithRetry(key, maxRetries) {
    let lastError;

    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        console.log(`Fetching ${key}, attempt ${attempt}/${maxRetries}`);

        const controller = new AbortController();
        const timeoutId = setTimeout(() => {
          controller.abort();
          console.log(`Request timeout for ${key}`);
        }, 8000);  // 8 second timeout

        const response = await fetch(
          `https://api.yourapp.com/v2/feature-flags/${key}`,
          {
            signal: controller.signal,
            headers: {
              'X-Retry-Attempt': attempt.toString(),
              'X-Request-ID': `${Date.now()}-${Math.random().toString(36)}`,
            },
          }
        );

        clearTimeout(timeoutId);

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }

        const data = await response.json();

        // Success - reset retry stats
        if (attempt > 1) {
          console.log(`Request succeeded on attempt ${attempt} for ${key}`);
          this.reportMetrics({
            successful_retry: key,
            attempts_needed: attempt,
          });
        }

        return data;

      } catch (error) {
        lastError = error;
        console.error(`Request attempt ${attempt} failed for ${key}:`, error.message);

        // Don't retry on certain errors
        if (error.name === 'AbortError' ||
            (error.message && error.message.includes('404'))) {
          break;
        }

        // Exponential backoff between retries
        if (attempt < maxRetries) {
          const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
          console.log(`Retrying ${key} in ${delay}ms`);
          await new Promise(resolve => setTimeout(resolve, delay));
        }
      }
    }

    // All retries failed
    this.reportMetrics({
      request_failed_after_retries: key,
      max_retries: maxRetries,
      final_error: lastError.message,
    });

    throw lastError;
  }
}

Testing Strategy

Unit Tests

import { renderHook, act } from '@testing-library/react-hooks';
import { useFeatureFlag } from '../useFeatureFlag';

// Mock AsyncStorage
jest.mock('@react-native-async-storage/async-storage', () => ({
  getItem: jest.fn(),
  setItem: jest.fn(),
}));

// Mock fetch
global.fetch = jest.fn();

describe('useFeatureFlag', () => {
  beforeEach(() => {
    fetch.mockClear();
    AsyncStorage.getItem.mockClear();
    AsyncStorage.setItem.mockClear();
  });

  it('should return cached data immediately', async () => {
    // Setup cache
    flagCache.setCache('test-flag', true);

    const { result } = renderHook(() =>
      useFeatureFlag('test-flag')
    );

    expect(result.current.data).toBe(true);
    expect(result.current.isLoading).toBe(false);
  });

  it('should revalidate stale data', async () => {
    fetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve(false)
    });

    // Setup stale cache (older than 1 minute)
    const staleTimestamp = Date.now() - 120000;
    flagCache.cache.set('test-flag', {
      data: true,
      timestamp: staleTimestamp,
      isValidating: false
    });

    const { result, waitForNextUpdate } = renderHook(() =>
      useFeatureFlag('test-flag')
    );

    // Should return stale data immediately
    expect(result.current.data).toBe(true);

    // Wait for revalidation
    await waitForNextUpdate();

    expect(result.current.data).toBe(false);
    expect(fetch).toHaveBeenCalledWith(
      'https://api.yourapp.com/v2/feature-flags/test-flag',
      expect.any(Object)
    );
  });

  it('should handle optimistic updates', async () => {
    const { result } = renderHook(() =>
      useFeatureFlag('test-flag', { fallbackData: false })
    );

    act(() => {
      result.current.mutate(true, false); // Optimistic update
    });

    expect(result.current.data).toBe(true);
  });
});

Integration Tests

import { render, waitFor } from '@testing-library/react-native';
import { FeatureFlagProvider } from '../FeatureFlagProvider';
import TestComponent from './TestComponent';

describe('Feature Flag Integration', () => {
  it('should handle app state changes', async () => {
    const { getByText } = render(
      <FeatureFlagProvider>
        <TestComponent />
      </FeatureFlagProvider>
    );

    // Simulate app going to background and foreground
    AppState.currentState = 'background';
    AppState.currentState = 'active';

    // Emit app state change event
    AppState.addEventListener.mock.calls[0][1]('active');

    await waitFor(() => {
      expect(fetch).toHaveBeenCalled();
    });
  });
});

Best Practices

1. Flag Naming Conventions

// Good: descriptive and hierarchical
'checkout.payment-v2.enabled'
'ui.dark-mode.rollout-percentage'
'experiment.recommendation-algorithm.variant'

// Bad: vague or inconsistent
'flag1'
'newThing'
'test_feature'

2. Error Boundaries

import React from 'react';

class FeatureFlagErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Feature flag error:', error, errorInfo);
    // Log to crash reporting service
    crashlytics().recordError(error);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || this.props.children;
    }

    return this.props.children;
  }
}

// Usage
function App() {
  return (
    <FeatureFlagErrorBoundary fallback={<LegacyComponent />}>
      <FeatureFlagComponent />
    </FeatureFlagErrorBoundary>
  );
}

3. Gradual Rollouts

function useGradualRollout(flagName, userId, percentage = 0) {
  const { data: flag } = useFeatureFlag(flagName);

  const isEnabled = useMemo(() => {
    if (!flag?.enabled) return false;

    // Consistent hash-based rollout
    const hash = hashString(userId + flagName);
    const userPercentile = hash % 100;

    return userPercentile < (flag.rolloutPercentage || percentage);
  }, [flag, userId, percentage, flagName]);

  return isEnabled;
}

function hashString(str) {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i);
    hash = ((hash << 5) - hash) + char;
    hash = hash & hash; // Convert to 32-bit integer
  }
  return Math.abs(hash);
}

Conclusion

Stale-while-revalidate is the right default whenever a slightly stale flag value is safer than a slow one. UI rollouts, experiment assignment, and progressive enhancement all tolerate a value that is a few minutes old, and in exchange the flag read never blocks a render. The cost is bounded staleness. A user can act on the previous value until the next revalidation lands, so the cache TTL is the worst-case window in which a bad flag stays live.

Override that default where staleness is not acceptable. Entitlement checks, anything that gates paid content, and consent switches should either read from an authoritative call or read from cache and then be re-verified server-side before the action commits. Kill switches need the same care: pair them with a short TTL or a push channel, because a five-minute cache means a five-minute rollback.

Once the cache is stable, the useful next step is shortening that window with a push channel, so a kill switch reaches devices without waiting for the next focus event.

References

Related posts