JSON Feature Flags: Schema Design, Targeting Rules, and Remote Config

Last updated:

JSON feature flags decouple deployment from release — a flags JSON object returned from a config API lets you enable features for 1% of users, specific user IDs, or entire organizations without a code deploy. A minimal flag schema looks like: {"flagKey": {"enabled": true, "rules": [{"condition": "userId in [123, 456]", "value": true}], "defaultValue": false}}. Percentage rollouts use a consistent hash of userId modulo 100 to ensure the same user always gets the same treatment across sessions and devices.

This guide covers JSON flag schema design, percentage rollout hashing, targeting rule evaluation order, the LaunchDarkly JSON payload format, the OpenFeature SDK and its JSON evaluation context, and how to serve flags efficiently via CDN-cached JSON endpoints. Whether you are building a homegrown flag system or integrating a commercial provider, the JSON structures and evaluation patterns described here apply across all implementations.

JSON Feature Flag Schema Design

A well-designed flag schema balances expressiveness with simplicity. Start with the minimum fields required for boolean flags, then extend with variations for multivariate flags and metadata for operational context. Every flag needs a stable flagKey (the identifier used in code), an enabled boolean that acts as a master kill switch, a rules array for targeting, and a defaultValue returned when no rule matches.

// flags.json — complete schema for a single flag
{
  "checkout-v2": {
    "enabled": true,
    "defaultValue": false,
    "variations": {
      "on": true,
      "off": false
    },
    "rules": [
      {
        "id": "rule-internal-users",
        "condition": { "attribute": "email", "op": "endsWith", "value": "@acme.com" },
        "variation": "on",
        "priority": 1
      },
      {
        "id": "rule-beta-segment",
        "condition": { "attribute": "segment", "op": "in", "value": ["beta-users"] },
        "variation": "on",
        "priority": 2
      },
      {
        "id": "rule-rollout",
        "rollout": { "percentage": 10, "attribute": "userId" },
        "variation": "on",
        "priority": 3
      }
    ],
    "metadata": {
      "createdAt": "2025-05-01",
      "owner": "payments-team",
      "jiraTicket": "PAY-1234",
      "expiresAt": "2025-08-01"
    }
  }
}

The enabled field is the master switch — when false, skip all rule evaluation and return defaultValue immediately. This lets you disable a flag in production instantly without waiting for CDN cache expiry if you add a cache-busting mechanism (such as a version query parameter).

Multivariate flags use a variations map so that rules reference variation keys ("on", "off", "control", "treatment-a") rather than hard-coded values. This makes it easy to add a third variation without changing the rules structure. For JSON Schema patterns that validate a flags file at build time, use a $defs block to define reusable rule and condition schemas.

The metadata block is not used during evaluation — it exists for operational tooling: flag dashboards, audit logs, and automated staleness detection. An expiresAt field lets a CI check warn when a flag has outlived its intended lifespan, preventing flag debt accumulation.

Percentage Rollout with Consistent Hashing

Percentage rollouts assign users to treatment or control groups deterministically, without storing per-user state. The key insight is that a stable hash of the user identifier and the flag key produces the same bucket number every time, so users do not flip between groups across page loads or sessions.

// rollout.ts — deterministic percentage rollout
import { createHash } from 'crypto'

function getBucket(flagKey: string, userId: string): number {
  // Hash flagKey + userId so different flags assign users to different buckets
  const hash = createHash('sha256')
    .update(flagKey + userId)
    .digest('hex')

  // Take first 4 bytes as a 32-bit unsigned integer
  const intValue = parseInt(hash.slice(0, 8), 16)

  // Modulo 100 gives a stable bucket from 0 to 99
  return intValue % 100
}

function evaluateRollout(
  flagKey: string,
  userId: string,
  rolloutPercentage: number  // 0-100
): boolean {
  return getBucket(flagKey, userId) < rolloutPercentage
}

// Usage: ramp from 0% to 100% by increasing rolloutPercentage
// Same userId always gets the same result for a given flagKey
console.log(evaluateRollout('checkout-v2', 'user-42', 10))  // true or false

Including flagKey in the hash input is critical. Without it, all flags roll out to exactly the same 10% of users at 10% rollout — the same users are always in every treatment group simultaneously. By salting the hash with the flag key, each flag independently assigns users to buckets, avoiding correlated experiments.

To gradually ramp up a feature, increment rolloutPercentage in the flags JSON and republish. Users at bucket 0–9 are in treatment at 10%; users at bucket 0–49 are in treatment at 50%. Because the bucket assignment is monotonic, users who were in treatment at 10% remain in treatment at 50% — they are never removed from the group as the rollout grows, which prevents jarring UX reversals.

If you need to restart a rollout with a fresh user assignment (for example, after a bad deploy), change the salt — either the flagKey itself (rename the flag) or add a rolloutSalt field to the rule JSON that is included in the hash input.

Targeting Rule Evaluation

Targeting rules are evaluated in priority order — the first rule whose condition matches the evaluation context wins, and the evaluation short-circuits. Rules with lower priority numbers (or appearing earlier in the array) are checked first, so place the most specific rules (individual user overrides) before broad segment rules before the percentage rollout rule.

// evaluator.ts — rule evaluation engine
interface EvaluationContext {
  userId: string
  email?: string
  segments?: string[]
  country?: string
  plan?: string
  [key: string]: unknown
}

interface Rule {
  id: string
  condition?: { attribute: string; op: string; value: unknown }
  rollout?: { percentage: number; attribute: string }
  variation: string
  priority: number
}

interface Flag {
  enabled: boolean
  defaultValue: unknown
  variations: Record<string, unknown>
  rules: Rule[]
}

function evaluateFlag(flag: Flag, context: EvaluationContext): unknown {
  // Master kill switch
  if (!flag.enabled) return flag.defaultValue

  // Sort by priority ascending (lower number = higher priority)
  const sortedRules = [...flag.rules].sort((a, b) => a.priority - b.priority)

  for (const rule of sortedRules) {
    if (rule.condition && matchesCondition(rule.condition, context)) {
      return flag.variations[rule.variation]
    }
    if (rule.rollout) {
      const attrValue = String(context[rule.rollout.attribute])
      if (evaluateRollout('flagKey', attrValue, rule.rollout.percentage)) {
        return flag.variations[rule.variation]
      }
    }
  }

  return flag.defaultValue
}

function matchesCondition(
  condition: { attribute: string; op: string; value: unknown },
  context: EvaluationContext
): boolean {
  const attrValue = context[condition.attribute]
  switch (condition.op) {
    case 'eq': return attrValue === condition.value
    case 'in': return Array.isArray(condition.value) && condition.value.includes(attrValue)
    case 'endsWith': return typeof attrValue === 'string' && attrValue.endsWith(String(condition.value))
    default: return false
  }
}

User override rules — where condition.op is "in" and the value is a specific list of user IDs — should always have priority: 1 so they fire before any other rule. This lets you force a specific user into a flag state for QA or support purposes without modifying the main rollout rule.

Segment membership is evaluated against a list of segment keys in the evaluation context. Segments themselves are resolved server-side before evaluation — the client only receives a list of segment keys the user belongs to, not the segment definitions. This keeps the flags JSON small and avoids exposing segment membership criteria to clients. For JSON data validation of the evaluation context, validate that required attributes are present before calling the evaluator.

LaunchDarkly JSON Format

LaunchDarkly SDKs receive a JSON payload from the streaming (/sdk/eval/bulk) or polling (/sdk/evalx/users/{userKey}) endpoints. The payload contains pre-evaluated flag values for the current user — the server does the rule evaluation, and the client receives ready-to-use values. This differs from a self-hosted flag system where the client evaluates rules locally against a full flags JSON.

// LaunchDarkly SDK payload — allFlags() response shape
{
  "checkout-v2": true,
  "new-dashboard": "control",
  "max-retry-count": 3
}

// LaunchDarkly allFlagsState() response — includes metadata
{
  "$flagsState": {
    "checkout-v2": {
      "variation": 1,
      "version": 42,
      "reason": {
        "kind": "RULE_MATCH",
        "ruleIndex": 0,
        "ruleId": "rule-beta-segment"
      }
    },
    "new-dashboard": {
      "variation": 0,
      "version": 7,
      "reason": { "kind": "FALLTHROUGH" }
    }
  },
  "$valid": true,
  "checkout-v2": true,
  "new-dashboard": "control"
}

The reason object in allFlagsState tells you why a user received a particular variation: RULE_MATCH (a targeting rule matched), FALLTHROUGH (no rule matched, default rollout applied), TARGET_MATCH (user was individually targeted), or OFF (flag is disabled). This is invaluable for debugging unexpected flag behavior.

LaunchDarkly sends full flag state on initial connection and sends patch events over Server-Sent Events as flags change. Each patch event contains the flag key, the new variation index, and the updated version number. The SDK applies the patch to its in-memory flag state without requiring a full re-fetch. The average initialization payload of 2–5 KB grows with the number of flags and variations — archive stale flags regularly to keep the payload small.

The version field enables optimistic concurrency: if a streaming patch arrives with a version lower than the currently cached version, the SDK discards it as a stale event. This prevents out-of-order SSE messages from reverting a flag to an older state.

OpenFeature SDK and JSON Providers

OpenFeature standardizes the feature flag API so application code does not depend on a specific provider. The SDK defines typed evaluation methods (getBooleanValue, getStringValue, getNumberValue, getObjectValue) and a provider interface that any flag backend can implement. The evaluation context is a plain JSON-serializable object passed to every evaluation call.

// OpenFeature with in-memory JSON provider (for local dev / tests)
import { OpenFeature, InMemoryProvider } from '@openfeature/server-sdk'

// Flag configuration JSON — OpenFeature flag definition schema
const flagConfig = {
  'checkout-v2': {
    disabled: false,
    variants: { on: true, off: false },
    defaultVariant: 'off',
    contextEvaluator: (context) =>
      context.email?.endsWith('@acme.com') ? 'on' : 'off',
  },
}

// Register the provider — swap to LaunchDarkly/flagd in production
await OpenFeature.setProviderAndWait(new InMemoryProvider(flagConfig))

const client = OpenFeature.getClient()

// Evaluation context is a plain JSON object
const context = {
  targetingKey: 'user-42',
  email: 'alice@acme.com',
  plan: 'enterprise',
  country: 'US',
}

const isEnabled = await client.getBooleanValue('checkout-v2', false, context)
console.log(isEnabled) // true — email matches acme.com rule

The flagdbackend is OpenFeature's reference JSON-based flag server. It reads a flag configuration file in OpenFeature's JSON schema and exposes a gRPC and HTTP evaluation API. The flag configuration file is version-controlled alongside your code — flag changes go through pull requests, code review, and CI validation.

// flagd flag configuration JSON (flags.flagd.json)
{
  "$schema": "https://flagd.dev/schema/v0/flags.json",
  "flags": {
    "checkout-v2": {
      "state": "ENABLED",
      "variants": { "on": true, "off": false },
      "defaultVariant": "off",
      "targeting": {
        "if": [
          { "ends_with": [{ "var": "email" }, "@acme.com"] },
          "on",
          "off"
        ]
      }
    }
  }
}

OpenFeature hooks allow you to run logic before and after each flag evaluation — for example, logging evaluation results to an observability platform or tracking experiment exposures. Hooks receive the full evaluation context and result as JSON-serializable objects, making it straightforward to forward data to analytics pipelines.

Serving Flags via CDN-Cached JSON

For client-side flag evaluation, serve the full flags JSON from an endpoint that CDN edge nodes can cache. The goal is to minimize origin load while keeping flag changes propagating quickly to clients. The correct cache strategy depends on how frequently flags change and how much latency you can tolerate between a flag change and users seeing the update.

// Next.js App Router: app/api/flags/route.ts
import { NextResponse } from 'next/server'
import { readFlagsFromDB } from '@/lib/flags'
import { createHash } from 'crypto'

export async function GET() {
  const flags = await readFlagsFromDB()
  const payload = JSON.stringify(flags)

  // ETag: SHA-256 of the flags JSON — clients skip parsing on 304
  const etag = '"' + createHash('sha256').update(payload).digest('hex').slice(0, 16) + '"'

  return new NextResponse(payload, {
    headers: {
      'Content-Type': 'application/json',
      // CDN caches for 30s, serves stale for 10s while revalidating
      'Cache-Control': 'public, max-age=30, stale-while-revalidate=10',
      ETag: etag,
      Vary: 'Accept-Encoding',
    },
  })
}

// Client: poll with ETag to skip re-parsing when flags haven't changed
async function pollFlags(etag: string | null) {
  const headers: Record<string, string> = {}
  if (etag) headers['If-None-Match'] = etag

  const res = await fetch('/api/flags', { headers })
  if (res.status === 304) return null  // flags unchanged

  const newEtag = res.headers.get('ETag')
  const flags = await res.json()
  return { flags, etag: newEtag }
}

The stale-while-revalidate directive is the key to balancing freshness and performance. With max-age=30, stale-while-revalidate=10, a CDN node serves a cached response immediately (no origin latency) for up to 40 seconds after the cache was last populated, revalidating in the background after 30 seconds. Users almost never wait for an origin round-trip.

For near-real-time flag updates without polling, use Server-Sent Events on a separate endpoint. The flags JSON endpoint remains CDN-cached for initial load; the SSE stream pushes patch events that clients apply to their in-memory flag state. This is the architecture LaunchDarkly and flagd both use. See JSON caching strategies for additional patterns including cache invalidation via CDN purge APIs.

If your flags JSON contains user-targeting rules (not pre-evaluated values), set Cache-Control: public only if the rules are not user-specific. Per-user evaluated payloads must use Cache-Control: private, no-storeto prevent one user's evaluated flags from being served to another user by a shared CDN cache.

Testing with Feature Flags

Tests that depend on real flag evaluation are fragile — flag values in production can change independently of the test code, causing random test failures. The solution is to inject flag state as a static JSON override map in all tests, completely bypassing the flag provider.

// test/setup.ts — global flag override for all tests
import { OpenFeature, InMemoryProvider } from '@openfeature/server-sdk'

// All flags default to their off/control state in tests
const testFlagDefaults = {
  'checkout-v2': {
    disabled: false,
    variants: { on: true, off: false },
    defaultVariant: 'off',  // off by default in tests
  },
  'new-dashboard': {
    disabled: false,
    variants: { control: 'v1', treatment: 'v2' },
    defaultVariant: 'control',
  },
}

beforeAll(async () => {
  await OpenFeature.setProviderAndWait(new InMemoryProvider(testFlagDefaults))
})

// Per-test flag override
test('shows new checkout when flag is on', async () => {
  // Override just this flag for this test
  await OpenFeature.setProviderAndWait(
    new InMemoryProvider({
      ...testFlagDefaults,
      'checkout-v2': {
        ...testFlagDefaults['checkout-v2'],
        defaultVariant: 'on',
      },
    })
  )

  // ... test assertions
})

For percentage rollout tests, override the hash function to return a deterministic value so you can control which bucket a test user lands in. Pass a hashFn parameter to your rollout evaluator and substitute a mock in tests: () => 5 puts a user in the treatment group at 10% rollout; () => 95 puts them in control.

// evaluator.ts — injectable hash function for testability
export function evaluateRollout(
  flagKey: string,
  userId: string,
  rolloutPercentage: number,
  hashFn: (flagKey: string, userId: string) => number = getBucket
): boolean {
  return hashFn(flagKey, userId) < rolloutPercentage
}

// In tests:
const inTreatment = evaluateRollout('checkout-v2', 'user-42', 10, () => 5)  // true
const inControl = evaluateRollout('checkout-v2', 'user-99', 10, () => 50)   // false

Integration tests should run against a local flagd instance or an in-memory provider loaded from a test flags JSON file. Never point integration tests at a staging or production flag environment — environment state can change mid-test-suite run, causing flaky failures. Store the test flags JSON file in your repository so flag state for integration tests is reproducible and code-reviewed. For JSON config management patterns that apply to flags JSON files, including schema validation in CI, see the config management guide.

Definitions

Feature flag
A configuration value, typically stored as JSON, that controls whether a feature is active for a given user or segment. Unlike a static config value, a feature flag includes targeting rules and is evaluated per-request against an evaluation context. Feature flags decouple code deployment from feature release, allowing teams to ship code and release features independently.
Targeting rule
A condition in a flag's JSON rules array that determines which variation a user receives. A rule specifies an attribute (such as email, country, or plan), an operator (eq, in, endsWith), and a value to compare against. Rules are evaluated in priority order; the first matching rule wins.
Percentage rollout
A targeting strategy that assigns a configurable percentage of users to a flag variation. Implemented by hashing a user identifier with the flag key, taking the result modulo 100, and comparing it to the rollout percentage threshold. Percentage rollouts are used to gradually ramp new features from 0% to 100% of users while monitoring error rates and metrics.
Consistent hash
A deterministic hashing technique that assigns a user to the same bucket on every evaluation without storing per-user state. In feature flags, hash(flagKey + userId) % 100 produces a stable bucket number from 0 to 99. Because the hash is deterministic, the same user always receives the same flag variation as long as the flag key and rollout percentage are unchanged.
Flag variation
One of the possible values a feature flag can return. Boolean flags have two variations (true/false). Multivariate flags have named variations (e.g., control, treatment-a, treatment-b) that map to typed values (strings, numbers, JSON objects). Variations are defined in the flag's JSON schema and referenced by name in targeting rules.
Evaluation context
A JSON-serializable object passed to the flag evaluator containing attributes about the current user or request: userId, email, country, plan, segments, and any custom attributes. The evaluation context is matched against targeting rule conditions to determine which variation the user receives. OpenFeature standardizes the evaluation context structure across providers.
OpenFeature
A CNCF (Cloud Native Computing Foundation) open standard that defines a vendor-neutral API for feature flag evaluation. OpenFeature providers implement the standard interface for specific backends (LaunchDarkly, flagd, Unleash, Harness). Application code depends only on the OpenFeature SDK, not on any specific provider, making it possible to switch flag backends without changing application code.

FAQ

What is a JSON feature flag schema?

A JSON feature flag schema defines the structure of a feature flag object. At minimum it includes a flagKey identifier, an enabled boolean, a rules array of targeting conditions, and a defaultValue returned when no rule matches. A minimal example: {"flagKey": {"enabled": true, "rules": [{"condition": "userId in [123, 456]", "value": true}], "defaultValue": false}}. Multivariate flags extend this with a variations map so rules can reference named variation keys rather than hard-coded values.

How do I implement a percentage rollout with JSON flags?

Store a rolloutPercentage number (0–100) in the flag's rule JSON. At evaluation time, compute hash(flagKey + userId) % 100 and compare it to rolloutPercentage. If the result is less than the percentage, the user is in the treatment group. Use a deterministic hash function (SHA-256 or MurmurHash) so the same user always lands in the same bucket. Increase rolloutPercentage in the flags JSON and republish to ramp the feature up gradually.

What is consistent hashing in feature flags?

Consistent hashing means the same user always receives the same flag variation without storing per-user state. It is implemented by hashing a combination of the flag key and a stable user identifier using a deterministic algorithm, then taking the result modulo 100 to produce a bucket number from 0 to 99. If the bucket is below the rollout threshold, the user is in the treatment group. Including the flag key in the hash input ensures each flag assigns users to independent buckets, preventing correlated experiment assignments.

How does LaunchDarkly serialize feature flags to JSON?

LaunchDarkly SDKs receive a JSON payload from streaming or polling endpoints containing pre-evaluated flag values for the current user. The allFlags() method returns a flat JSON object mapping flag keys to their evaluated values. The allFlagsState() method returns a richer payload that includes a $flagsState object with the variation index, version number, and a reason object explaining why the user received that variation (e.g., RULE_MATCH, FALLTHROUGH, or TARGET_MATCH). The initialization payload averages 2–5 KB when all flag variations are included.

What is OpenFeature and how does it use JSON?

OpenFeature is a CNCF open standard that defines a vendor-neutral API for feature flag evaluation. It uses a JSON-serializable "evaluation context" object — containing attributes like userId, email, country, and plan— that is passed to the provider at evaluation time. The flagd backend uses a JSON flag configuration file in OpenFeature's schema, making flag definitions version-controllable alongside code. OpenFeature providers implement the standard interface for specific backends so application code is decoupled from any particular flag vendor.

How do I serve feature flags from a CDN?

Serve flags from an endpoint with Cache-Control: public, max-age=30, stale-while-revalidate=10. This allows CDN edge nodes to cache the flags JSON for 30 seconds and serve stale content for 10 more seconds while revalidating in the background. Use ETags so clients can skip re-parsing with a conditional If-None-Match request — the CDN returns a 304 Not Modified response when the payload is unchanged. For near-real-time updates, combine CDN caching for initial load with a Server-Sent Events channel for incremental patch events.

How do I test code that uses feature flags?

In unit tests, inject a static JSON flag override map using the OpenFeature InMemoryProvider or a custom provider stub, bypassing the real flag provider entirely. For percentage rollout tests, make the hash function injectable and substitute a mock that returns a fixed bucket number so you can control treatment vs. control group assignment deterministically. In integration tests, run against a local flagd instance loaded from a test flags JSON file committed to your repository. Never point tests at staging or production flag environments — live flag changes can cause flaky test failures.

What is the difference between a feature flag and a config value?

A feature flag controls whether a feature is active for a specific user or segment and includes targeting rules, percentage rollout logic, and an evaluation context. It changes frequently as rollouts progress and requires an evaluation engine. A config value is a static setting (like a timeout duration or an API URL) that applies uniformly to all users and changes only during deployments. In practice, the line blurs: multivariate flags returning strings or numbers behave like per-user dynamic config. The key distinction is per-user vs. global: if all users receive the same value, it's a config value; if different users receive different values based on their attributes, it's a feature flag.

Further reading and primary sources