JSON Feature Flags: Schema Design, Targeting Rules & A/B Testing
Last updated:
Most feature flag articles explain how to use LaunchDarkly or GrowthBook — they skip the most interesting part: the JSON schema that drives everything. A well-designed JSON feature flag schema specifies flag keys, enabled booleans, rollout percentages, targeting rules as structured condition arrays, and A/B test variants with weights. This guide covers schema design from scratch, consistent hashing for deterministic rollout, targeting rule JSON structure with AND/OR operator nesting, A/B test variant schemas, self-hosted flags.json with polling and fallback, the OpenFeature CNCF standard and JSON provider interface, and TypeScript mock providers for test isolation. Every section includes full JSON examples you can copy into your own system.
JSON Feature Flag Schema Design
A feature flag JSON schema needs to encode both the configuration (what the flag controls) and the evaluation logic (who gets it). The minimum viable schema has five fields: a stable key, an enabled master switch, a defaultValue for unmatched users, a targeting rules array, and a rolloutPercentage (0–100). For A/B testing, add a variants array and an experimentKey. Flag keys must be stable identifiers — changing a key after deploy requires coordinating application code and config simultaneously.
// Minimum viable feature flag schema
{
"key": "new-checkout-ui",
"description": "Redesigned checkout flow with inline payment",
"enabled": true,
"defaultValue": false,
"rolloutPercentage": 20,
"targeting": [],
"createdAt": "2026-02-13T00:00:00Z",
"tags": ["checkout", "ui"],
"schemaVersion": 1
}
// Full schema with targeting + variants
{
"key": "recommendation-engine",
"description": "ML-powered product recommendations",
"enabled": true,
"defaultValue": "static",
"rolloutPercentage": 100,
"targeting": [
{
"description": "Internal team always gets ML variant",
"conditions": [
{ "attribute": "email", "operator": "endsWith", "value": "@acme.com" }
],
"value": "ml-v2"
},
{
"description": "Free plan users get static (capacity reasons)",
"conditions": [
{ "attribute": "plan", "operator": "equals", "value": "free" }
],
"value": "static"
}
],
"variants": [
{ "key": "static", "weight": 50, "value": "static" },
{ "key": "ml-v1", "weight": 30, "value": "ml-v1" },
{ "key": "ml-v2", "weight": 20, "value": "ml-v2" }
],
"experimentKey": "rec-engine-q1-2026",
"createdAt": "2026-02-13T00:00:00Z",
"tags": ["recommendations", "ml", "experiment"]
}
// TypeScript types for the schema
type FlagValue = boolean | string | number | Record<string, unknown>
interface TargetingCondition {
attribute: string
operator: 'in' | 'equals' | 'startsWith' | 'endsWith' | 'matches' | 'greaterThan' | 'lessThan'
value: FlagValue | FlagValue[]
}
interface TargetingRule {
description?: string
conditions: TargetingCondition[]
value: FlagValue
}
interface FlagVariant {
key: string
weight: number // 0–100, all weights must sum to 100
value: FlagValue
}
interface FeatureFlag {
key: string
description: string
enabled: boolean
defaultValue: FlagValue
rolloutPercentage: number // 0–100
targeting: TargetingRule[]
variants?: FlagVariant[]
experimentKey?: string
createdAt: string
tags: string[]
schemaVersion: number
}
// Top-level flags.json structure
interface FlagsConfig {
version: number
updatedAt: string
flags: FeatureFlag[]
}
// Example flags.json
{
"version": 47,
"updatedAt": "2026-05-20T12:00:00Z",
"flags": [
{
"key": "new-checkout-ui",
"enabled": true,
"defaultValue": false,
"rolloutPercentage": 20,
"targeting": [],
"createdAt": "2026-02-13T00:00:00Z",
"tags": ["checkout"],
"schemaVersion": 1
}
]
}Always validate new flags against a JSON Schema in CI before merging — a typo in a flag key or a rolloutPercentage outside 0–100 can silently break evaluation. The version field on the top-level config enables optimistic polling: clients can compare the version number to avoid re-parsing unchanged configs. For JSON Schema validation tooling to validate your flags config on commit, see the linked guide.
Targeting Rules in JSON
Targeting rules are an ordered array evaluated top-to-bottom — the first matching rule wins. Each rule has a conditions array (AND logic within a rule) and a value to serve when matched. For OR logic, write multiple rules that return the same value. The condition operators follow LaunchDarkly's four-operator model: in (membership), endsWith, startsWith, and matches (regex). Extended implementations add equals, greaterThan, and lessThan for numeric and string comparisons.
// Targeting rule examples
// 1. User allowlist (in operator)
{
"conditions": [
{
"attribute": "userId",
"operator": "in",
"value": ["u-001", "u-002", "u-003"]
}
],
"value": true
}
// 2. Country + plan (AND conditions — both must match)
{
"description": "US enterprise users get the feature",
"conditions": [
{ "attribute": "country", "operator": "equals", "value": "US" },
{ "attribute": "plan", "operator": "equals", "value": "enterprise" }
],
"value": true
}
// 3. OR logic — two rules that return the same value
[
{
"description": "Pro users in US",
"conditions": [
{ "attribute": "country", "operator": "equals", "value": "US" },
{ "attribute": "plan", "operator": "equals", "value": "pro" }
],
"value": true
},
{
"description": "Pro users in CA",
"conditions": [
{ "attribute": "country", "operator": "equals", "value": "CA" },
{ "attribute": "plan", "operator": "equals", "value": "pro" }
],
"value": true
}
]
// 4. Email domain targeting (endsWith)
{
"conditions": [
{ "attribute": "email", "operator": "endsWith", "value": "@acme.com" }
],
"value": "internal-build"
}
// 5. Beta opt-in via attribute (in operator on boolean)
{
"conditions": [
{ "attribute": "betaOptIn", "operator": "equals", "value": true }
],
"value": true
}
// 6. Regex matching (matches operator)
{
"description": "UK and Ireland by phone prefix",
"conditions": [
{ "attribute": "phonePrefix", "operator": "matches", "value": "^\\+4[47]" }
],
"value": "gb-ie-variant"
}
// TypeScript evaluation function
function evaluateTargeting(
rules: TargetingRule[],
context: Record<string, FlagValue>
): FlagValue | null {
for (const rule of rules) {
if (matchesAllConditions(rule.conditions, context)) {
return rule.value
}
}
return null // no rule matched — fall through to rollout/default
}
function matchesAllConditions(
conditions: TargetingCondition[],
context: Record<string, FlagValue>
): boolean {
return conditions.every(c => evaluateCondition(c, context))
}
function evaluateCondition(
cond: TargetingCondition,
context: Record<string, FlagValue>
): boolean {
const attrValue = context[cond.attribute]
if (attrValue === undefined) return false
switch (cond.operator) {
case 'equals': return attrValue === cond.value
case 'in': return (cond.value as FlagValue[]).includes(attrValue)
case 'startsWith': return String(attrValue).startsWith(String(cond.value))
case 'endsWith': return String(attrValue).endsWith(String(cond.value))
case 'matches': return new RegExp(String(cond.value)).test(String(attrValue))
case 'greaterThan': return Number(attrValue) > Number(cond.value)
case 'lessThan': return Number(attrValue) < Number(cond.value)
default: return false
}
}Rule ordering is critical: always place the most specific rules first (userId allowlists before country rules, country rules before plan rules) so narrow exceptions are not shadowed by broad rules. An empty targeting array means all users go directly to the rollout percentage evaluation. When a targeting rule matches, the rolloutPercentage is bypassed — targeting rules always take precedence over percentage rollout.
Percentage-Based Rollout in JSON
Percentage rollout assigns users deterministically to a bucket using consistent hashing: hash(userId + flagKey) % 100. If the result is less than rolloutPercentage, the user is in the rollout. This guarantees the same user always gets the same assignment for the same flag — no database needed, no session cookie, no flicker between sessions. The flag key is included in the hash input so a user in the top 10% for one flag is statistically independent from another flag.
// JSON rollout configuration
{
"key": "dark-mode-toggle",
"enabled": true,
"defaultValue": false,
"rolloutPercentage": 10, // start at 10%
"targeting": []
}
// Rollout progression (update JSON, no code deploy needed):
// Phase 1: rolloutPercentage: 10 — canary release
// Phase 2: rolloutPercentage: 50 — half rollout
// Phase 3: rolloutPercentage: 100 — full release
// TypeScript: consistent hashing with FNV-1a (fast, no dependency)
function fnv1a(str: string): number {
let hash = 2166136261 // FNV offset basis (32-bit)
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i)
hash = (hash * 16777619) >>> 0 // FNV prime, keep 32-bit unsigned
}
return hash
}
function getUserBucket(userId: string, flagKey: string): number {
return fnv1a(userId + '/' + flagKey) % 100
}
// Full flag evaluation with rollout
function evaluateFlag(
flag: FeatureFlag,
userId: string,
context: Record<string, FlagValue>
): FlagValue {
// Master switch
if (!flag.enabled) return flag.defaultValue
// 1. Targeting rules (bypass rollout when matched)
const targetingValue = evaluateTargeting(flag.targeting, context)
if (targetingValue !== null) return targetingValue
// 2. Percentage rollout
const bucket = getUserBucket(userId, flag.key)
if (bucket >= flag.rolloutPercentage) return flag.defaultValue
// 3. Variant assignment (for A/B tests)
if (flag.variants && flag.variants.length > 0) {
return assignVariant(flag.variants, userId, flag.experimentKey ?? flag.key)
}
// 4. Simple boolean/value flag
return true
}
// Why consistent hashing beats random assignment:
// Random: user sees feature → refreshes → feature gone (bad UX)
// Consistent hash: user always in same bucket (same experience)
// Sticky assignment — compute once, cache the result
// NEVER recalculate per-request if you can avoid it:
const stickyCache = new Map<string, number>()
function getStickyBucket(userId: string, flagKey: string): number {
const cacheKey = userId + '/' + flagKey
if (!stickyCache.has(cacheKey)) {
stickyCache.set(cacheKey, fnv1a(cacheKey) % 100)
}
return stickyCache.get(cacheKey)!
}
// Expanding rollout is safe — existing cohort stays in
// hash result 7 is in the top 10% (bucket < 10)
// hash result 7 is also in the top 50% (bucket < 50)
// — the 10% cohort is a subset of the 50% cohortWhen you increase rolloutPercentage from 10 to 50, the original 10% cohort stays included — you are expanding the bucket boundary, not reshuffling. This is a critical property of consistent hashing-based rollout. Sticky assignment (caching the bucket in memory or a session store) avoids the small but non-zero cost of recomputing the hash on every request — important for high-frequency flag evaluations on hot paths.
A/B Test Variants in JSON
A/B test variants extend the base flag schema with a variants array and an experimentKey. Each variant has a key (identifier for analytics), a weight (integer, all weights must sum to 100), and a value (the payload — can be a string, boolean, number, or object). Variant assignment uses the same consistent hashing algorithm as rollout but maps the bucket to a variant using cumulative weight ranges.
// A/B test flag JSON schema
{
"key": "pricing-page-layout",
"description": "Test three pricing page layouts",
"enabled": true,
"defaultValue": "control",
"rolloutPercentage": 100,
"experimentKey": "pricing-q2-2026",
"variants": [
{ "key": "control", "weight": 34, "value": "control" },
{ "key": "variant-a", "weight": 33, "value": "variant-a" },
{ "key": "variant-b", "weight": 33, "value": "variant-b" }
],
"targeting": [
{
"description": "QA team always gets variant-b",
"conditions": [
{ "attribute": "userId", "operator": "in", "value": ["qa-001", "qa-002"] }
],
"value": "variant-b"
}
]
}
// A/B variant with object values (for complex configuration)
{
"key": "checkout-button",
"experimentKey": "cta-copy-test",
"variants": [
{
"key": "control",
"weight": 50,
"value": { "text": "Buy Now", "color": "#0070f3", "size": "md" }
},
{
"key": "variant-a",
"weight": 50,
"value": { "text": "Get Started", "color": "#7c3aed", "size": "lg" }
}
],
"defaultValue": { "text": "Buy Now", "color": "#0070f3", "size": "md" },
"enabled": true,
"rolloutPercentage": 80,
"targeting": []
}
// TypeScript: variant assignment via cumulative weight
function assignVariant(
variants: FlagVariant[],
userId: string,
experimentKey: string
): FlagValue {
const bucket = fnv1a(userId + '/' + experimentKey) % 100
let cumulative = 0
for (const variant of variants) {
cumulative += variant.weight
if (bucket < cumulative) return variant.value
}
// Fallback if weights don't sum to 100 exactly
return variants[variants.length - 1].value
}
// TypeScript: typed variant hook for React
type ButtonVariant = { text: string; color: string; size: 'sm' | 'md' | 'lg' }
function useVariant<T>(flagKey: string, defaultValue: T): T {
const { userId } = useUser()
const flag = useFlagConfig(flagKey)
if (!flag || !flag.enabled) return defaultValue
const value = evaluateFlag(flag, userId, { userId })
return value as T
}
// Usage in a React component:
// const button = useVariant<ButtonVariant>('checkout-button', defaultButton)
// Exposure tracking — fire on evaluation, not on conversion
function evaluateFlagWithExposure(
flag: FeatureFlag,
userId: string,
context: Record<string, FlagValue>
): FlagValue {
const value = evaluateFlag(flag, userId, context)
// Track ONCE per user per flag per session to avoid double-counting
analytics.track('flag_evaluated', {
flagKey: flag.key,
experimentKey: flag.experimentKey,
variant: value,
userId,
})
return value
}
// Cleanup: when experiment ends, replace variants with defaultValue
// Before:
// "variants": [{"key":"control","weight":50,"value":"old"},{"key":"variant-a","weight":50,"value":"new"}]
// After winning variant-a:
// "defaultValue": "new",
// "variants": [] <-- empty array, flag becomes simple on/offTrack exposure events on first flag evaluation, not on conversion. Exposure tracking at conversion time introduces selection bias — only users who converted are counted, skewing your experiment metrics. When an experiment concludes and you pick a winning variant, update defaultValue to the winner's value and empty the variants array — the flag continues to work as a simple feature flag until it can be cleaned up entirely.
Self-Hosted JSON Feature Flags
Self-hosting feature flags with a flags.json file has three layers: a bundled default file for compile-time safety, an in-memory cache updated by background polling, and a webhook endpoint for instant invalidation. The evaluation path must never make a network call — always read from the in-memory cache synchronously.
// flags.json (committed to repo as compile-time defaults)
{
"version": 1,
"updatedAt": "2026-02-13T00:00:00Z",
"flags": [
{
"key": "new-checkout-ui",
"enabled": false,
"defaultValue": false,
"rolloutPercentage": 0,
"targeting": [],
"createdAt": "2026-02-13T00:00:00Z",
"tags": ["checkout"],
"schemaVersion": 1
}
]
}
// TypeScript: self-hosted flag service with polling + fallback
import defaultFlags from './flags.json'
class JSONFlagService {
private cache: FlagsConfig = defaultFlags as FlagsConfig
private pollInterval: ReturnType<typeof setInterval> | null = null
constructor(
private readonly remoteUrl: string,
private readonly pollMs: number = 60_000 // 60s default
) {}
async initialize(): Promise<void> {
await this.fetchAndUpdate() // initial fetch on startup
this.startPolling() // background refresh
}
private async fetchAndUpdate(): Promise<void> {
try {
const res = await fetch(this.remoteUrl, {
headers: { 'If-None-Match': this.cache.version.toString() },
signal: AbortSignal.timeout(5_000), // 5s timeout
})
if (res.status === 304) return // not modified
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: FlagsConfig = await res.json()
if (data.version > this.cache.version) {
this.cache = data // atomic in-memory swap
console.log('[flags] updated to version', data.version)
}
} catch (err) {
// Log and continue — keep serving last-known-good cache
console.error('[flags] fetch failed, using cached config:', err)
}
}
private startPolling(): void {
this.pollInterval = setInterval(
() => { void this.fetchAndUpdate() },
this.pollMs
)
}
// Webhook invalidation endpoint — call fetchAndUpdate immediately
async handleWebhookInvalidation(): Promise<void> {
await this.fetchAndUpdate()
}
// Synchronous evaluation — never blocks, always < 1ms
evaluate(flagKey: string, userId: string, context: Record<string, FlagValue>): FlagValue {
const flag = this.cache.flags.find(f => f.key === flagKey)
if (!flag) {
console.warn(`[flags] unknown flag "${flagKey}", returning false`)
return false
}
return evaluateFlag(flag, userId, context)
}
destroy(): void {
if (this.pollInterval) clearInterval(this.pollInterval)
}
}
// Express webhook endpoint for instant invalidation
// app.post('/webhooks/flags-updated', async (req, res) => {
// await flagService.handleWebhookInvalidation()
// res.json({ ok: true })
// })
// Remote config hosting options:
// Option 1: S3 bucket (simplest — upload flags.json, serve via CloudFront CDN)
// const REMOTE_URL = 'https://d1234.cloudfront.net/flags.json'
//
// Option 2: Your own API (flexible — can add auth, versioning)
// const REMOTE_URL = 'https://api.acme.com/internal/feature-flags'
//
// Option 3: GitHub raw URL (free — PR-based flag changes with review)
// const REMOTE_URL = 'https://raw.githubusercontent.com/acme/flags/main/flags.json'
// Graceful startup with bundled defaults
const flagService = new JSONFlagService(
process.env.FLAGS_URL ?? 'https://cdn.acme.com/flags.json',
30_000 // poll every 30 seconds
)
// Non-blocking startup — app serves bundled defaults until remote loads
flagService.initialize().catch(err => {
console.error('[flags] failed to initialize from remote, using defaults:', err)
})The three-layer fallback (bundled default -> last-known-good cache -> remote fetch) ensures the application never fails due to a flags service outage. The 5-second fetch timeout prevents a slow remote endpoint from hanging application startup. For production deployments, host flags.json on a CDN with aggressive cache headers — the webhook invalidation pattern means you can set Cache-Control: max-age=3600 while still getting sub-second propagation when you need it.
OpenFeature Standard and JSON Providers
OpenFeature is the CNCF standard for feature flag evaluation — a vendor-neutral SDK specification with a pluggable provider interface. The SDK exposes four typed evaluation methods (getBooleanValue, getStringValue, getNumberValue, getObjectValue). Providers implement the FeatureProvider interface, which means you can swap your JSON file provider for a managed service (LaunchDarkly, Flagsmith, GrowthBook) in one line without touching flag call sites.
// npm install @openfeature/server-sdk
import {
OpenFeature,
Provider,
ResolutionDetails,
EvaluationContext,
StandardResolutionReasons,
} from '@openfeature/server-sdk'
// JSON file provider implementing the OpenFeature FeatureProvider interface
class JSONFileProvider implements Provider {
readonly metadata = { name: 'JSON File Provider' }
constructor(private readonly flagService: JSONFlagService) {}
async initialize(): Promise<void> {
await this.flagService.initialize()
}
resolveBooleanEvaluation(
flagKey: string,
defaultValue: boolean,
context: EvaluationContext
): ResolutionDetails<boolean> {
return this.resolveEvaluation(flagKey, defaultValue, context)
}
resolveStringEvaluation(
flagKey: string,
defaultValue: string,
context: EvaluationContext
): ResolutionDetails<string> {
return this.resolveEvaluation(flagKey, defaultValue, context)
}
resolveNumberEvaluation(
flagKey: string,
defaultValue: number,
context: EvaluationContext
): ResolutionDetails<number> {
return this.resolveEvaluation(flagKey, defaultValue, context)
}
resolveObjectEvaluation<T extends object>(
flagKey: string,
defaultValue: T,
context: EvaluationContext
): ResolutionDetails<T> {
return this.resolveEvaluation(flagKey, defaultValue, context)
}
private resolveEvaluation<T>(
flagKey: string,
defaultValue: T,
context: EvaluationContext
): ResolutionDetails<T> {
const userId = String(context.targetingKey ?? 'anonymous')
const userContext = context as Record<string, FlagValue>
const flag = this.flagService.getFlag(flagKey)
if (!flag) {
return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT }
}
const targetingValue = evaluateTargeting(flag.targeting, userContext)
if (targetingValue !== null) {
return {
value: targetingValue as T,
reason: StandardResolutionReasons.TARGETING_MATCH,
}
}
const bucket = getUserBucket(userId, flagKey)
if (!flag.enabled || bucket >= flag.rolloutPercentage) {
return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT }
}
const resolved = evaluateFlag(flag, userId, userContext) as T
return {
value: resolved,
reason: StandardResolutionReasons.STATIC,
variant: flag.variants?.find(v => v.value === resolved)?.key,
}
}
}
// Registration — swap providers without changing call sites
const provider = new JSONFileProvider(flagService)
await OpenFeature.setProviderAndWait(provider)
const client = OpenFeature.getClient()
// Flag evaluation in application code
const isEnabled = await client.getBooleanValue(
'new-checkout-ui',
false, // default
{ targetingKey: userId, country: 'US', plan: 'pro' }
)
const layout = await client.getStringValue(
'pricing-page-layout',
'control',
{ targetingKey: userId }
)
// Switch to managed provider in production (zero call-site changes):
// import { LaunchDarklyProvider } from '@launchdarkly/openfeature-server-provider'
// await OpenFeature.setProviderAndWait(new LaunchDarklyProvider(sdkKey))
// OpenFeature hooks for telemetry
import { Hook, BeforeHookContext, AfterHookContext } from '@openfeature/server-sdk'
const telemetryHook: Hook = {
before(hookContext: BeforeHookContext) {
// Start a timer
hookContext.flagValueType // 'boolean' | 'string' | 'number' | 'object'
hookContext.flagKey // flag identifier
},
after(hookContext: AfterHookContext<unknown>, details: ResolutionDetails<unknown>) {
// Record flag evaluation in your APM
metrics.histogram('feature_flag_eval_ms', /* elapsed */ 0, {
flag: hookContext.flagKey,
reason: details.reason,
})
},
}
OpenFeature.addHooks(telemetryHook)The OpenFeature hook system is the right place for logging, telemetry, and analytics tracking — not in flag call sites scattered across the codebase. The reason field in ResolutionDetails tells you whether the resolution was a targeting match, default, or static value — essential for diagnosing why a user received a specific variant.
Testing with JSON Feature Flags
Flaky feature flag tests share one root cause: shared mutable state. The fix is flag isolation per test — each test controls its own flag values and resets them in teardown. The OpenFeature InMemoryProvider pattern is the most reliable approach: construct a provider with a flags map, set it before the test, and tear it down after. No network calls, no file I/O, sub-millisecond resolution.
// npm install @openfeature/server-sdk
import { OpenFeature, InMemoryProvider } from '@openfeature/server-sdk'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
// InMemoryProvider accepts a flags map — fully synchronous
describe('CheckoutPage with new-checkout-ui flag', () => {
afterEach(async () => {
// CRITICAL: always reset to avoid state leaking between tests
await OpenFeature.clearProviders()
})
it('shows new checkout UI when flag is enabled for user', async () => {
await OpenFeature.setProviderAndWait(
new InMemoryProvider({
'new-checkout-ui': {
defaultVariant: 'on',
variants: { on: true, off: false },
disabled: false,
},
})
)
const client = OpenFeature.getClient()
const result = await client.getBooleanValue('new-checkout-ui', false, {
targetingKey: 'u-123',
})
expect(result).toBe(true)
})
it('falls back to old checkout when flag is disabled', async () => {
await OpenFeature.setProviderAndWait(
new InMemoryProvider({
'new-checkout-ui': {
defaultVariant: 'off',
variants: { on: true, off: false },
disabled: false,
},
})
)
const client = OpenFeature.getClient()
const result = await client.getBooleanValue('new-checkout-ui', false, {
targetingKey: 'u-456',
})
expect(result).toBe(false)
})
})
// Custom mock provider for full evaluation logic (targeting + rollout)
class MockFlagProvider implements Provider {
readonly metadata = { name: 'Mock Flag Provider' }
private overrides = new Map<string, FlagValue>()
setOverride(flagKey: string, value: FlagValue): void {
this.overrides.set(flagKey, value)
}
clearOverrides(): void {
this.overrides.clear()
}
resolveBooleanEvaluation(flagKey: string, defaultValue: boolean): ResolutionDetails<boolean> {
const value = this.overrides.get(flagKey)
return value !== undefined
? { value: value as boolean, reason: 'OVERRIDE' }
: { value: defaultValue, reason: StandardResolutionReasons.DEFAULT }
}
resolveStringEvaluation(flagKey: string, defaultValue: string): ResolutionDetails<string> {
const value = this.overrides.get(flagKey)
return value !== undefined
? { value: value as string, reason: 'OVERRIDE' }
: { value: defaultValue, reason: StandardResolutionReasons.DEFAULT }
}
resolveNumberEvaluation(flagKey: string, defaultValue: number): ResolutionDetails<number> {
const value = this.overrides.get(flagKey)
return value !== undefined
? { value: value as number, reason: 'OVERRIDE' }
: { value: defaultValue, reason: StandardResolutionReasons.DEFAULT }
}
resolveObjectEvaluation<T extends object>(flagKey: string, defaultValue: T): ResolutionDetails<T> {
const value = this.overrides.get(flagKey)
return value !== undefined
? { value: value as T, reason: 'OVERRIDE' }
: { value: defaultValue, reason: StandardResolutionReasons.DEFAULT }
}
}
// Test fixture: snapshot flags.json so changes require deliberate update
// test/fixtures/flags.json (committed, never fetched from remote in tests)
// import testFlags from './fixtures/flags.json'
// Vitest setup file (vitest.setup.ts):
// const mockProvider = new MockFlagProvider()
// OpenFeature.setProvider(mockProvider)
// globalThis.__mockFlags = mockProvider
// Per-test usage:
describe('PricingPage A/B test', () => {
const provider = new MockFlagProvider()
beforeEach(async () => {
await OpenFeature.setProviderAndWait(provider)
})
afterEach(() => {
provider.clearOverrides()
})
it('renders variant-a layout', async () => {
provider.setOverride('pricing-page-layout', 'variant-a')
// ... render and assert
expect(screen.getByTestId('layout-variant-a')).toBeInTheDocument()
})
it('renders control layout by default', async () => {
// No override — defaultValue is 'control'
expect(screen.getByTestId('layout-control')).toBeInTheDocument()
})
})Commit a test/fixtures/flags.json snapshot and validate it in CI — any flag change must update the fixture, making flag schema changes visible in code review. Never test against a live remote flag service: network calls make tests slow and environment-dependent. The clearOverrides() call in afterEach is the most important line in your test setup — without it, a flag override from one test contaminates all subsequent tests in the file.
Key Terms
- Feature Flag
- A configuration value (typically stored as JSON) that controls whether a feature is enabled for a given user or context, without requiring a code deployment. Feature flags decouple deployment from release — code ships to production disabled, and the flag is updated to enable it for subsets of users. A feature flag JSON object minimally contains a key, an enabled boolean, a defaultValue, and a targeting rules array. The key is used in application code to query the flag; the enabled boolean acts as a master circuit breaker; the defaultValue is returned to users who do not match any targeting rule or rollout bucket. Feature flags enable canary releases, A/B testing, kill switches, and beta programs. They should be temporary: establish a flag lifetime policy (typically 30–90 days) and remove flags after the feature is fully launched or rolled back.
- Targeting Rule
- A JSON object within a feature flag's targeting array that defines conditions under which a specific value is returned, overriding the default. A targeting rule has a conditions array (evaluated with AND logic — all must match) and a value to serve when the rule matches. Conditions specify an attribute (a field from the evaluation context, e.g., userId, country, plan), an operator (in, equals, startsWith, endsWith, matches, greaterThan, lessThan), and a comparison value. Rules are evaluated in order; the first matching rule wins. For OR logic between segments, use multiple rules with the same value. Targeting rules take precedence over percentage rollout — a user who matches a targeting rule gets its value regardless of their rollout bucket. Rules enable use cases like internal team early access (email endsWith @company.com), plan-based gating (plan equals "enterprise"), user allowlists (userId in ["u-001", "u-002"]), and geographic targeting (country equals "US").
- Rollout Percentage
- A number (0–100) in a feature flag JSON schema that controls what fraction of users receive the enabled value when no targeting rule matches. A rolloutPercentage of 0 means nobody gets the feature (flag is effectively off despite enabled: true); 100 means everyone gets it. Values between 0 and 100 implement a canary release: 10% for initial validation, 50% for broader testing, 100% for full launch. The rollout percentage gates entry to the feature after all targeting rules have been evaluated and none matched. Users who pass the rollout gate (their hash bucket is below the percentage threshold) then proceed to variant assignment for A/B tests. Increasing the percentage is safe — the existing in-bucket cohort stays in because consistent hashing assigns the same bucket deterministically. Decreasing the percentage (rolling back) is equally safe and instantaneous — update the JSON, and the new percentage takes effect on the next flag evaluation.
- Consistent Hashing
- A deterministic algorithm that maps an input (userId concatenated with flagKey) to a stable bucket (0–99) using a hash function such as FNV-1a or MurmurHash. The formula is: hash(userId + '/' + flagKey) % 100. The result is compared to rolloutPercentage — if less than the percentage, the user is in the rollout. Consistent hashing provides three guarantees: determinism (same inputs always produce the same bucket), independence across flags (appending flagKey ensures each flag has its own bucket distribution), and no state requirement (no database row or session cookie needed). The use of the flagKey in the hash input is critical: without it, all flags would map users to the same bucket, creating correlation between rollouts. FNV-1a is the recommended hash function for feature flags — it is non-cryptographic, extremely fast (sub-microsecond), and produces good distribution with low collision probability for typical userId + flagKey inputs.
- Sticky Assignment
- The property that a user always receives the same feature flag value across sessions and requests, preventing variant flicker. Sticky assignment is achieved in two ways: first, by computing the assignment from a deterministic hash of stable user attributes (userId + flagKey) — this is inherently sticky because the same inputs always produce the same result. Second, by caching the resolved value in a session store (Redis, cookie, localStorage) to avoid even the minimal cost of re-hashing on every request. The alternative — random assignment stored in a database — achieves stickiness but requires a database read on every flag evaluation, which is prohibitively expensive for sub-millisecond targets. Sticky assignment is especially important for A/B tests: if a user sees variant A on request 1 and variant B on request 2 due to inconsistent assignment, their behavior metrics are corrupted across both variants. Consistent hashing provides stickiness for free without any state.
- A/B Test Variant
- An entry in a feature flag's variants array that defines one arm of an A/B experiment. Each variant has a key (string identifier for analytics grouping), a weight (integer 0–100, all variant weights must sum to 100), and a value (the payload — boolean, string, number, or object). Variant assignment uses consistent hashing: hash(userId + experimentKey) % 100 maps to a cumulative weight range. For example, with weights [50, 30, 20], hash result 0–49 maps to variant 0, 50–79 to variant 1, 80–99 to variant 2. The experimentKey field (separate from the flag key) groups analytics events across the lifetime of the experiment, even if the underlying flag key changes. Exposure events should be tracked on first flag evaluation (not on conversion) to avoid selection bias. When an experiment concludes, the winning variant's value becomes the defaultValue and the variants array is emptied — the flag degrades gracefully to a simple feature toggle pending removal.
- OpenFeature
- A CNCF (Cloud Native Computing Foundation) incubating project that defines a vendor-neutral specification for feature flag evaluation SDKs. The OpenFeature SDK exposes a uniform client API: getBooleanValue(flagKey, default, context), getStringValue(), getNumberValue(), and getObjectValue(). Providers implement the FeatureProvider interface — which includes resolveBooleanEvaluation() and equivalent methods for each type — and are registered with OpenFeature.setProvider(). This architecture means application code calls the OpenFeature SDK without knowing or caring whether the underlying provider is a JSON file, a managed service (LaunchDarkly, Flagsmith, GrowthBook, Unleash), or an in-memory mock. Switching providers requires one line of code in initialization with zero changes to flag call sites. The hook system allows adding telemetry, logging, and validation around every flag evaluation globally. OpenFeature is available for Node.js, Java, Go, .NET, PHP, Ruby, and Python.
- Remote Evaluation
- A feature flag architecture where the flag evaluation logic runs on a server rather than in the client SDK. The client sends an evaluation context (userId, attributes) to a remote API and receives a resolved flag value. Contrasted with local evaluation, where the SDK downloads the full flag config and evaluates rules locally. Remote evaluation simplifies client-side SDKs and keeps flag rules private, but adds network latency to every flag resolution — incompatible with the sub-millisecond evaluation target for server-side flags. For client-side (browser, mobile) feature flags, remote evaluation at session start is acceptable: fetch all flag values once on page load, cache them in memory, and serve subsequent evaluations synchronously from the cache. The OpenFeature RemoteEvaluation provider pattern implements this: the provider fetches resolved values from your evaluation API during initialization and serves cached results synchronously. For server-side flags in the request path, always use local evaluation with in-memory cached config.
FAQ
What should a JSON feature flag schema include?
A well-designed JSON feature flag schema needs at minimum five fields: a unique key string that identifies the flag in code, an enabled boolean that acts as the master on/off switch, a defaultValue for unmatched users, a targeting array containing ordered rules for specific segments, and a rolloutPercentage (0–100). For A/B testing, add a variants array where each entry has a weight and a value. For auditability, include description, createdAt, tags, and schemaVersion. The flag key must be a stable identifier — changing it requires a coordinated code and config deploy. Always validate new flags against a JSON Schema in CI: a rolloutPercentage outside 0–100 or a missing required field can silently break evaluation. The top-level config object should include a version integer and updatedAt timestamp to enable optimistic polling — clients compare the version number to skip re-parsing unchanged configs.
How do I implement percentage-based rollout in a JSON feature flag system?
Use consistent hashing: compute hash(userId + '/' + flagKey) % 100 and compare the result to rolloutPercentage. If the bucket is less than the percentage, the user is in the rollout. FNV-1a is the recommended hash function — it is non-cryptographic, sub-microsecond, and has good distribution for typical userId strings. The flagKey must be included in the hash input so each flag has an independent bucket distribution — without it, a user in the top 10% for one flag would always be in the top 10% for all flags. Consistent hashing is inherently sticky: the same userId and flagKey always produce the same bucket, so users never see variant flicker. When increasing rolloutPercentage from 10 to 50, the original 10% cohort remains included because you are expanding the bucket boundary, not reshuffling. Implement sticky caching (a Map<string, number>) to avoid even the minimal hash computation on every request for high-frequency flags.
How do I write targeting rules in JSON to show a feature to specific user segments?
Targeting rules are an ordered array evaluated top-to-bottom. Each rule has a conditions array (AND logic — all conditions must match) and a value to serve. For AND: put multiple conditions in one rule. For OR: use multiple rules that return the same value. Condition operators: in for membership checks (userId in allowlist), equals for exact match (country equals "US"), startsWith/endsWith for string prefix/suffix (email endsWith "@acme.com"), matches for regex (phone matches "^\\+1"). Always place the most specific rules first: userId allowlists before country rules, country rules before plan rules. An empty conditions array in a rule would match all users — only use that pattern for a catch-all "else" rule at the end. Targeting rules bypass rolloutPercentage: a user who matches a targeting rule gets its value regardless of their hash bucket. Attributes in conditions must match keys in the evaluation context object passed at flag evaluation time — missing attributes evaluate the condition as false.
How do I run A/B tests using JSON feature flag variants?
Add a variants array to the flag where each entry has key (analytics identifier), weight (integer, all weights sum to 100), and value (the variant payload). Set an experimentKey field for analytics grouping. Assign users by computing hash(userId + '/' + experimentKey) % 100 and walking the variants array with cumulative weights: weights [50, 30, 20] map bucket 0–49 to variant 0, 50–79 to variant 1, 80–99 to variant 2. Track an exposure event on first flag evaluation (not on conversion) to avoid selection bias. The value can be any JSON type — use an object for complex variant configuration like { "text": "Buy Now", "color": "#0070f3" }. In TypeScript, model the value as a generic type parameter for compile-time safety. When the experiment ends, set defaultValue to the winning variant's value and empty the variants array — the flag degrades to a simple feature toggle until it can be deleted.
How do I implement feature flags without a third-party service using a JSON file?
Use a three-layer approach. Layer 1: bundle a flags.json file as compile-time defaults — the app works even if the remote config is unreachable. Layer 2: on startup, fetch the latest flags from a remote URL (S3 bucket, CDN, GitHub raw URL, or your own API) and replace the in-memory defaults. Layer 3: poll the remote endpoint on a configurable interval (30–300 seconds) and swap the cache atomically on version change. Flag evaluation must be synchronous and always read from the in-memory cache — never make a network call during evaluation on the request path. Use a 5-second fetch timeout with AbortSignal to prevent slow endpoints from hanging startup. For instant invalidation, implement a webhook endpoint that triggers an immediate fetch — this reduces propagation lag from minutes to seconds. Always implement fallback: if the remote fetch fails, log the error and serve the last-known-good cached value. Host the remote flags.json on a CDN with a long cache TTL (3600 seconds) — webhook invalidation handles the freshness requirement separately.
What is OpenFeature and how does it standardize JSON feature flag providers?
OpenFeature is the CNCF standard for feature flag evaluation — a vendor-neutral SDK specification with a pluggable provider interface. The SDK exposes four typed evaluation methods: getBooleanValue(flagKey, default, context), getStringValue(), getNumberValue(), and getObjectValue(). Providers implement the FeatureProvider interface by supplying resolveBooleanEvaluation() and equivalent methods that receive the flag key, default value, and evaluation context, and return a ResolutionDetails object with the resolved value, reason (TARGETING_MATCH, DEFAULT, STATIC, UNKNOWN), and optional variant name. Register a provider with OpenFeature.setProvider(new JSONFileProvider(...)). To switch from your JSON file provider to LaunchDarkly in production, call OpenFeature.setProvider(new LaunchDarklyProvider(sdkKey)) — zero changes to flag call sites. The hook system adds telemetry, logging, and validation globally around every flag evaluation. Available for Node.js, Java, Go, .NET, PHP, Ruby, and Python.
How do I test code that uses JSON feature flags without flaky tests?
The root cause of flaky feature flag tests is shared mutable state. Use three isolation patterns. First, the InMemoryProvider pattern: construct a provider with a flags map in beforeEach and call OpenFeature.clearProviders() in afterEach — this gives each test a clean, isolated flag state. Second, the override pattern: implement a MockFlagProvider with setOverride(flagKey, value) and clearOverrides() methods — call clearOverrides() in afterEach to prevent leakage between tests. Third, commit a test/fixtures/flags.json snapshot and load it as test defaults — flag config changes become visible in code review because they require updating the fixture. Never test against a live remote flag service: network calls make tests slow, order-dependent, and environment-sensitive. Model both your production JSONFileProvider and test MockFlagProvider as implementations of the same Provider interface — this ensures test code exercises the same evaluation code paths as production.
Further reading and primary sources
- OpenFeature Specification — CNCF vendor-neutral feature flag evaluation standard — provider interface, hooks, and SDK specification
- OpenFeature JavaScript SDK — Official OpenFeature SDK for Node.js and browser — provider registration, flag evaluation API, and hooks
- LaunchDarkly JSON Flag Rule Schema — LaunchDarkly's JSON targeting rule format with in, endsWith, startsWith, and matches operators
- GrowthBook Feature Flag JSON Schema — GrowthBook open-source feature flag schema including targeting rules, rollout, and A/B test variants
- Feature Flags Guide: Martin Fowler — Canonical reference on feature flag categories, lifecycle, and best practices for toggle management