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:
- Cold starts: a Lambda cold start plus a Parameter Store round trip can push the response into seconds
- No caching: every checkout pays the full network cost again
- Cascading timeouts: one slow flag makes every screen that reads it slow
- 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:
- Return cached data instantly (even if it’s stale)
- Fetch fresh data in background (revalidate)
- 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:
- FeatureFlagCache: In-memory cache with AsyncStorage persistence
- useFeatureFlag: SWR-style hook with background revalidation
- Smart invalidation: Automatic updates on app focus/network changes
- 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.
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
- SWR - React Hooks for Data Fetching - Official SWR documentation explaining the stale-while-revalidate pattern for instant responses with background revalidation
- Feature Toggles (aka Feature Flags) - Martin Fowler - Comprehensive guide to feature toggle patterns, categories, and management strategies
- RFC 5861 - HTTP Cache-Control Extensions for Stale Content - The IETF specification defining stale-while-revalidate as a cache invalidation strategy
- AWS Systems Manager Parameter Store - Official AWS documentation for storing and managing configuration values and feature flags with IAM access control
- React Native Performance Overview - Official documentation on JavaScript thread performance relevant to synchronous flag loading overhead
- SWR - Automatic Revalidation - SWR documentation on revalidation triggers including focus, network recovery, and interval-based refresh
Related posts
A practical comparison of headless CMS options (Strapi, Contentful, Kontent, Storyblok) with Cloudinary image management and framework integration.
Server-Driven UI is the mobile analog of server-side composition. The hard part is not JSON rendering but a versioned component contract that survives old app versions.
How high-performing teams shrink the lead time from code-complete to live in production, without trading away security or code quality. A guide for tech leads.
A practical guide to mobile in-app purchase rules, paywall patterns, and RevenueCat integration with server-side receipt validation and event-driven architecture.
Step-by-step guide to adding Sentry to a React Native Expo app: SDK setup, Expo Router instrumentation, session replay, and source maps for EAS.