Need help with your JSON?
Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool
Using JSON for Feature Flags and Toggles
JSON is a strong fit for feature flags when you want readable configuration, version control, and a simple way to change behavior without redeploying logic. The important caveat is that JSON is only the storage format. You still need defaults, targeting rules, rollout stickiness, validation, and a cleanup process.
If you are landing here from search, the short answer is: use JSON when your flags change at engineering speed and are managed close to the codebase; move to a dedicated flag service when you need instant updates, audit history, approvals, non-engineer editing, or cross-app coordination.
Quick Decision Guide
| JSON file is enough when... | Use a managed flag platform when... |
|---|---|
| One service or a small set of apps share the same flags | Many apps, services, or teams need the same flag state |
| Changes can go through pull requests or a config deploy | You need instant flips, scheduled changes, or emergency kill switches |
| Engineering owns the full lifecycle of every flag | Product, support, or operations also need to change flags safely |
| PR history is enough for auditability | You need approvals, change history, metrics, or stale-flag reporting |
Model flags as typed configuration, not loose booleans
A flat list of true and false values works for the first few toggles, but it breaks down quickly. Modern flag systems increasingly treat flags as typed values: boolean, string, number, and structured JSON objects. That makes the same system useful for release toggles, UI copy experiments, thresholds, and feature-specific config payloads.
Even if you are keeping everything in a single JSON file, define a schema up front and include owner, lifetime, and default information. That makes the file safer to validate and easier to clean up later.
Example of a production-friendly JSON manifest
{
"$schema": "https://example.com/schemas/feature-flags.schema.json",
"flags": {
"checkout_redesign": {
"description": "Release the new checkout flow",
"flagType": "boolean",
"defaultValue": false,
"kind": "release",
"owner": "payments",
"expiresAt": "2026-05-01",
"clientSafe": true,
"environments": {
"production": {
"enabled": true,
"allowUserIds": ["emp_17", "emp_42"],
"rolloutPercentage": 25,
"stickiness": "userId"
}
}
},
"search_ranking": {
"description": "Tune ranking without code changes",
"flagType": "object",
"defaultValue": {
"algorithm": "bm25",
"maxResults": 20,
"minScore": 0.35
},
"kind": "operational",
"owner": "search",
"clientSafe": false
}
}
}Why this shape works
- Default values make evaluation safe even if parsing fails or no provider is loaded.
- Typed values let one flag system power both on/off switches and structured config.
- Owner and expiry metadata create an obvious cleanup path for temporary flags.
- Client-safe markers stop you from leaking internal rules or admin-only toggles.
Make percentage rollouts deterministic
Sticky rollout is where many homegrown JSON implementations fail. If you use Math.random() on every request, the same user can bounce in and out of the feature. Instead, hash a stable identifier such asuserId, sessionId, or another consistent context key.
JavaScript example: stable rollout bucketing
import crypto from "node:crypto";
function bucketFor(key) {
const hex = crypto.createHash("sha256").update(key).digest("hex").slice(0, 8);
return parseInt(hex, 16) % 100;
}
export function isFlagEnabled(flagKey, flag, context = {}) {
const envName = context.environment ?? "production";
const envConfig = flag.environments?.[envName];
if (!envConfig?.enabled) {
return flag.defaultValue;
}
if (envConfig.allowUserIds?.includes(context.userId)) {
return true;
}
if (envConfig.rolloutPercentage == null) {
return true;
}
if (!context.userId) {
return flag.defaultValue;
}
const rolloutKey = `${flagKey}:${context.userId}`;
return bucketFor(rolloutKey) < envConfig.rolloutPercentage;
}Keep the bucketing key explicit. If you change from userId to email later, the rollout population changes too. That can invalidate an experiment or make a gradual rollout look unstable.
Load raw JSON on the server, not directly in the browser
Store the full manifest server-side, evaluate flags there when possible, and send the browser only what it needs. That protects internal targeting rules, keeps entitlement logic out of public JavaScript, and reduces the chance of leaking future features.
Server-side loading with validation and a safe fallback
import fs from "node:fs/promises";
let cachedFlags = null;
function assertValidManifest(value) {
if (!value || typeof value !== "object" || !value.flags || typeof value.flags !== "object") {
throw new Error("Invalid feature flag manifest");
}
}
export async function loadFlags() {
if (cachedFlags) return cachedFlags;
const raw = await fs.readFile("./config/flags.json", "utf8");
const parsed = JSON.parse(raw);
assertValidManifest(parsed);
cachedFlags = parsed.flags;
return cachedFlags;
}
export function fallbackFlags() {
return {
checkout_redesign: { defaultValue: false, environments: {} }
};
}If you go beyond a static file, keep the same contract: validate before promoting a new version, cache aggressively, and refresh on explicit change events instead of fetching on every request.
What to put in the JSON, and what not to
| Good fit for JSON flags | Poor fit for JSON flags |
|---|---|
| Release toggles and kill switches | Secrets, API keys, or anything security-sensitive |
| Experiment variants and copy or layout choices | Authorization logic that only exists client-side |
| Thresholds, limits, and small config payloads | Large documents or frequently changing operational data |
| Per-environment differences and allowlists | Business rules that require heavy joins or complex workflow logic |
Treat flags as temporary assets with a lifecycle
One of the most useful ideas in current flag tooling is separating flags by purpose. Release and experiment flags are usually temporary and should be removed once the result is known. Operational kill switches may stay longer. Permission flags sometimes become permanent because they model product tiers.
Your JSON should reflect that reality. Add a kind field, give temporary flags anexpiresAt date, and review stale entries every sprint. If a flag has been permanently on for a month or two, the better fix is normally to delete the branching code.
Default-first rule
Every flag evaluation should have a hardcoded fallback value in application code. If parsing fails, the file is missing, the cache is stale, or a provider is unavailable, the app should still choose a known behavior.
Common mistakes when using JSON for feature toggles
| Mistake | Better approach |
|---|---|
| Random rollout on each request | Use deterministic hashing with a stable stickiness key |
| Only storing booleans | Support string, number, and object payloads where they reduce redeploys |
| Sending the full flag file to the browser | Expose only evaluated or client-safe values |
| No owner, no expiry, no cleanup | Track purpose, owner, and removal date in the JSON itself |
| Treating feature flags like permanent architecture | Delete temporary branches once the rollout is complete |
When to move beyond a plain JSON file
A plain JSON file stops being the right answer when the missing features start outweighing the simplicity. The most common triggers are real-time updates, advanced targeting, audit logs, stale-flag detection, experimentation metrics, or the need for multiple teams to edit flags without touching the codebase.
Until then, JSON is still a very solid foundation. Keep the schema explicit, make rollout decisions sticky, validate the document before use, expose only safe values to clients, and remove temporary flags quickly. That gets you most of the value of feature toggles without turning configuration into a second application.
Need help with your JSON?
Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool