Build a Feature Flag System with A/B Testing (Step-by-Step Guide)
In this tutorial, you'll learn about Build a Feature Flag System with A/B Testing (Step. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Build a feature flag management system with a React dashboard, an Express toggle API, gradual percentage-based rollouts, and A/B test targeting rules that let you safely ship features to specific user segments.
What You'll Build
You'll build a full-stack feature flag system where developers can create and toggle feature flags from a dashboard, set gradual rollout percentages, target specific users via group rules, and let applications query flags in real time. The system includes an audit log of every toggle change.
Why Feature Flags Matter
Feature flags let you decouple deployment from release. You can merge code to production without exposing it to users, gradually roll it out to 10% then 50% then 100%, and instantly kill it if something breaks — no rollback deployment needed. Every major tech company uses feature flags for canary releases and A/B testing. This pattern also applies to security: Durga Antivirus Pro uses flag-driven rule updates to push new detection signatures to a subset of users first, monitoring for false positives before full rollout.
Prerequisites
- Node.js 18+ installed
- Basic React and Express.js knowledge
Step 1: Project Setup
mkdir feature-flags
cd feature-flags
mkdir server client
cd server && npm init -y && npm install express cors uuid
cd ../client && npx create-react-app . 2>/dev/null || npm install react react-dom
Project structure:
feature-flags/
├── server/
│ ├── index.js # Express API server
│ ├── flags.js # Flag storage and logic
│ └── flags.json # Persisted flag definitions
└── client/
└── src/
├── App.js # Main dashboard component
└── FlagCard.js # Individual flag toggle card
Step 2: The Flag Engine
// server/flags.js
const fs = require("fs");
const path = require("path");
const { v4: uuidv4 } = require("uuid");
const FLAGS_FILE = path.join(__dirname, "flags.json");
function loadFlags() {
if (!fs.existsSync(FLAGS_FILE)) return {};
return JSON.parse(fs.readFileSync(FLAGS_FILE, "utf-8"));
}
function saveFlags(flags) {
fs.writeFileSync(FLAGS_FILE, JSON.stringify(flags, null, 2));
}
function createFlag(name, description) {
const flags = loadFlags();
const id = uuidv4();
flags[id] = {
id, name, description,
enabled: false,
rolloutPercentage: 100,
rules: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
auditLog: [],
};
saveFlags(flags);
return flags[id];
}
function evaluateFlag(flagId, userContext) {
const flags = loadFlags();
const flag = flags[flagId];
if (!flag) return false;
if (!flag.enabled) return false;
for (const rule of flag.rules) {
if (rule.type === "user" && userContext.userId === rule.value) {
return rule.enabled;
}
if (rule.type === "group" && userContext.group === rule.value) {
return rule.enabled;
}
if (rule.type === "percentage") {
const hash = hashString(userContext.userId || "anonymous");
return (hash % 100) < rule.value;
}
}
if (flag.rolloutPercentage < 100) {
const hash = hashString(userContext.userId || "anonymous");
return (hash % 100) < flag.rolloutPercentage;
}
return true;
}
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 |= 0;
}
return Math.abs(hash);
}
module.exports = { loadFlags, saveFlags, createFlag, evaluateFlag };
Step 3: The API Server
// server/index.js
const express = require("express");
const cors = require("cors");
const flagsEngine = require("./flags");
const app = express();
app.use(cors());
app.use(express.json());
app.get("/api/flags", (req, res) => {
res.json(flagsEngine.loadFlags());
});
app.post("/api/flags", (req, res) => {
const { name, description } = req.body;
if (!name) return res.status(400).json({ error: "name is required" });
const flag = flagsEngine.createFlag(name, description);
res.json(flag);
});
app.patch("/api/flags/:id", (req, res) => {
const flags = flagsEngine.loadFlags();
const flag = flags[req.params.id];
if (!flag) return res.status(404).json({ error: "Flag not found" });
const { enabled, rolloutPercentage } = req.body;
if (typeof enabled === "boolean") flag.enabled = enabled;
if (typeof rolloutPercentage === "number") flag.rolloutPercentage = rolloutPercentage;
flag.updatedAt = new Date().toISOString();
flag.auditLog.push({ action: "update", timestamp: flag.updatedAt, changes: req.body });
flagsEngine.saveFlags(flags);
res.json(flag);
});
app.post("/api/flags/:id/evaluate", (req, res) => {
const result = flagsEngine.evaluateFlag(req.params.id, req.body);
res.json({ enabled: result });
});
app.listen(4000, () => console.log("Flag API on http://localhost:4000"));
Expected output:
# Create a flag
curl -X POST http://localhost:4000/api/flags \
-H "Content-Type: application/json" \
-d '{"name":"new-checkout","description":"New checkout flow"}'
# Evaluate for a user
curl -X POST http://localhost:4000/api/flags/<flag-id>/evaluate \
-H "Content-Type: application/json" \
-d '{"userId":"user-123","group":"beta"}'
# Response: {"enabled": false} (flag is off by default)
Step 4: The React Dashboard
// client/src/App.js
import React, { useState, useEffect } from "react";
const API = "http://localhost:4000/api";
function App() {
const [flags, setFlags] = useState({});
const [newName, setNewName] = useState("");
useEffect(() => {
fetch(`${API}/flags`).then((r) => r.json()).then(setFlags);
}, []);
async function toggleFlag(id, enabled) {
await fetch(`${API}/flags/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
const updated = await fetch(`${API}/flags`).then((r) => r.json());
setFlags(updated);
}
async function createFlag() {
if (!newName.trim()) return;
await fetch(`${API}/flags`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName, description: "" }),
});
setNewName("");
const updated = await fetch(`${API}/flags`).then((r) => r.json());
setFlags(updated);
}
return (
<div style={{ fontFamily: "system-ui, sans-serif", maxWidth: 800, margin: "40px auto", padding: "0 20px" }}>
<h1>Feature Flags</h1>
<div style={{ display: "flex", gap: 8, marginBottom: 24 }}>
<input value={newName} onChange={(e) => setNewName(e.target.value)}
placeholder="New flag name" style={{ flex: 1, padding: "8px 12px", borderRadius: 6, border: "1px solid #ddd" }} />
<button onClick={createFlag} style={{ padding: "8px 16px", background: "#6366f1", color: "white", border: "none", borderRadius: 6, cursor: "pointer" }}>Create</button>
</div>
{Object.entries(flags).map(([id, flag]) => (
<div key={id} style={{ border: "1px solid #e2e8f0", borderRadius: 8, padding: 16, marginBottom: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<strong style={{ fontSize: "1.1rem" }}>{flag.name}</strong>
<span style={{ marginLeft: 12, padding: "2px 8px", borderRadius: 4, fontSize: "0.8rem",
background: flag.enabled ? "#dcfce7" : "#fee2e2", color: flag.enabled ? "#166534" : "#991b1b" }}>
{flag.enabled ? "ON" : "OFF"}
</span>
</div>
<label style={{ position: "relative", display: "inline-block", width: 48, height: 24 }}>
<input type="checkbox" checked={flag.enabled} onChange={(e) => toggleFlag(id, e.target.checked)}
style={{ opacity: 0, width: 0, height: 0 }} />
<span style={{ position: "absolute", cursor: "pointer", inset: 0, background: flag.enabled ? "#6366f1" : "#cbd5e1", borderRadius: 24, transition: "0.3s" }}>
<span style={{ position: "absolute", height: 20, width: 20, left: flag.enabled ? 26 : 2, bottom: 2, background: "white", borderRadius: "50%", transition: "0.3s" }} />
</span>
</label>
</div>
<p style={{ color: "#64748b", fontSize: "0.9rem", margin: "8px 0 0" }}>
Rollout: {flag.rolloutPercentage}% | Rules: {flag.rules.length} | Created: {new Date(flag.createdAt).toLocaleDateString()}
</p>
</div>
))}
</div>
);
}
export default App;
Expected output: Run node server/index.js and npm start in the client directory. The dashboard shows all flags with toggle switches, creation form, and flag metadata. Toggling a flag instantly updates the server state.
Architecture
flowchart LR
A[React Dashboard] -->|CRUD| B[Express API]
C[Application SDK] -->|Evaluate| B
B --> D[Flag Storage]
D --> E[Audit Log]
C --> F{Evaluate Rules}
F --> G[User Targeting]
F --> H[Group Targeting]
F --> I[Percentage Rollout]
F --> J[Global Toggle]
Common Errors
1. Flags not persisting after server restart
The demo stores flags in flags.json on disk. As long as the file is writable and the server has permissions, data persists. Check that flags.json exists in the server directory after creating a flag.
2. CORS errors from the React client
The Express server must include the cors() middleware. If the client and server run on different ports (3000 vs 4000), the browser blocks cross-origin requests without CORS headers.
3. User gets inconsistent flag evaluation
The hashString function must produce consistent results for the same input. If the rollout percentage changes between requests, a user might see different experiences. Log the hash to verify consistency.
4. Audit log grows unbounded
Each toggle change appends to the audit log array. For production, cap the log at 100 entries or move to a database with log rotation. Add: if (flag.auditLog.length > 100) flag.auditLog.shift().
5. Concurrent updates overwrite each other
flags.json is read, modified, and written. Two simultaneous requests can overwrite each other. Use file locking (proper-lockfile npm package) or switch to a database with atomic updates.
Practice Questions
1. What is the difference between a feature flag and a configuration setting? A configuration setting is static and changes with a deployment. A feature flag is dynamic — it can be toggled at runtime without redeploying, and can target specific users or percentages.
2. How does gradual rollout work in this system?
Each user gets a deterministic hash from their userId. The hash modulo 100 gives a number 0-99. If that number is below the rolloutPercentage, the flag evaluates to true. The same user always gets the same result.
3. Why would you use both global toggles and per-user rules? Global toggle is the kill switch — turn everything off if something goes wrong. Per-user rules allow targeted testing. In production, you combine them: global toggle must be ON, then percentage rollout applies per user.
4. Challenge: Add scheduled rollouts
Add a scheduledAt and rolloutSchedule field. When the server clock reaches scheduledAt, automatically increase rolloutPercentage by 10% every hour until it reaches 100%. Implement this with setInterval checking every minute.
5. Challenge: WebSocket live updates
Add Socket.IO to the server so that when a flag changes in the dashboard, all connected applications receive a flag-updated event with the new flag state. This enables instant feature toggles without polling.
FAQ
Next Steps
- Add user authentication and role-based dashboard access
- Explore React advanced patterns for better dashboard UX
- Try building the CI/CD Runner project for a related deployment tool
- Learn about canary deployments and how flags enable safe rollouts
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro