Feature Flags in Production: Ship Without Fear
Your deploy is done. But the feature isn’t ready. You wait. Days pass. Merge conflicts multiply. A long-running branch becomes a merge disaster.
Feature flags solve this. Deploy code dark. Turn it on when you’re ready. Ship every day. Release once a week.
The Four Types of Flags
🔹 Release flags: Feature incomplete? Ship it off. if (flags.newCheckout) { ... }
🔹 Experiment/A-B flags: 50% of users see variant A, 50% see B. Measure conversion.
🔹 Ops flags: Kill switches. If production melts, flip a flag. No deploy needed.
🔹 Permission flags: Beta testers, internal teams. if (flags.betaFeatures && user.isBeta) { ... }
Most teams need 2-3 types. Don’t over-engineer.
DIY: Redis + JSON
The simplest production setup: store flag config as JSON in Redis, evaluate per request.
Flag schema:
type FlagConfig = { [key: string]: { enabled: boolean; rollout?: number; // 0-100, percentage of users userIds?: string[]; // Specific user overrides environments?: string[]; // Only prod, only staging, etc }};Redis storage:
const flagConfig = { "newCheckout": { enabled: true, rollout: 25, // 25% of users environments: ["production"] }, "darkMode": { enabled: true, rollout: 100, userIds: ["user-123"] // Force on for this user }};
await redis.set("feature-flags", JSON.stringify(flagConfig));Evaluation logic:
async function isEnabled( flagName: string, userId: string, environment: string): Promise<boolean> { const config = JSON.parse(await redis.get("feature-flags")); const flag = config[flagName];
if (!flag || !flag.enabled) return false;
if (flag.environments && !flag.environments.includes(environment)) { return false; }
if (flag.userIds?.includes(userId)) return true;
if (flag.rollout) { const hash = hashUserId(userId); return (hash % 100) < flag.rollout; }
return true;}Hash the user ID consistently so they always see the same variant.
function hashUserId(userId: string): number { let hash = 0; for (let i = 0; i < userId.length; i++) { hash = ((hash << 5) - hash) + userId.charCodeAt(i); hash = hash & hash; } return Math.abs(hash);}Express Middleware: Attach Flags to Request
app.use(async (req, res, next) => { const userId = req.user?.id || "anonymous"; const env = process.env.ENVIRONMENT || "development";
req.flags = { newCheckout: await isEnabled("newCheckout", userId, env), darkMode: await isEnabled("darkMode", userId, env) };
next();});
app.get("/checkout", (req, res) => { if (req.flags.newCheckout) { return res.json(checkoutV2()); } return res.json(checkoutV1());});React: Context Provider
Fetch flags once on app load, provide via context.
type FlagsContextType = { newCheckout: boolean; darkMode: boolean; isLoading: boolean;};
const FlagsContext = createContext<FlagsContextType>({ newCheckout: false, darkMode: false, isLoading: true});
export function FlagsProvider({ children }: { children: React.ReactNode }) { const [flags, setFlags] = useState<FlagsContextType>({ newCheckout: false, darkMode: false, isLoading: true });
useEffect(() => { fetch("/api/flags") .then(r => r.json()) .then(data => setFlags({ ...data, isLoading: false })) .catch(() => setFlags(f => ({ ...f, isLoading: false }))); }, []);
return ( <FlagsContext.Provider value={flags}> {children} </FlagsContext.Provider> );}
export function useFlags() { return useContext(FlagsContext);}In a component:
export function Checkout() { const { newCheckout } = useFlags();
return newCheckout ? <CheckoutV2 /> : <CheckoutV1 />;}OpenFeature Standard
Don’t want to DIY? OpenFeature is a vendor-neutral SDK for feature flags. Works with any backend.
import { OpenFeature } from "@openfeature/web-sdk";
const provider = new YourFlagProvider();OpenFeature.setProvider(provider);
const client = OpenFeature.getClient();const isEnabled = client.getBooleanValue("newCheckout", false);Works with LaunchDarkly, Unleash, GrowthBook, Firebase, or your custom Redis backend.
GrowthBook: Self-Hosted Option
Want a UI for managing flags without building one? GrowthBook is open-source, self-hosted, and cheap.
import { GrowthBook } from "@growthbook/sdk-js";
const gb = new GrowthBook({ apiHost: "https://growthbook.internal.com", clientKey: "sdk_prod_xxx"});
await gb.loadFeatures();
if (gb.isOn("newCheckout")) { // Show new checkout}You get a dashboard to toggle flags, run A/B tests, see rollout %, all without touching code.
Flag Hygiene: Don’t Let Them Rot
Flags you forgot about become dead code. Three months later, half your conditional branches are unreachable.
Quarterly audit:
- Find all flags in use:
grep -r "isEnabled\|flags\." src/ - Check which are 100% enabled
- Remove them:
if (true) { ... }→ just the branch - Mark for deletion:
// TODO: remove newCheckout flag by 2026-03-01
Dead flags aren’t just cruft. They’re performance leaks (every request checks them) and confusion for new developers.
Testing With Flags
Test both paths. A feature behind a flag is still a feature.
describe("Checkout with newCheckout flag", () => { it("shows new checkout when enabled", async () => { await redis.set("feature-flags", JSON.stringify({ newCheckout: { enabled: true, rollout: 100 } }));
const res = await request(app).get("/checkout"); expect(res.body.version).toBe("v2"); });
it("shows old checkout when disabled", async () => { await redis.set("feature-flags", JSON.stringify({ newCheckout: { enabled: false } }));
const res = await request(app).get("/checkout"); expect(res.body.version).toBe("v1"); });});The Real Win
Feature flags decouple deploy (push code) from release (turn it on). This is everything.
Deploy 10 times a day. Release once. If something breaks, flags revert instantly. No rollback. No downtime. No Friday night pages.
That’s production velocity.