Feature Flags & Progressive Rollouts
Decoupling deploys from releases with feature flags — consistent user bucketing via hash-mod, dark launches, kill switches, percentage rollouts, flag evaluation in Server Components vs middleware, flag taxonomy, lifecycle governance, and avoiding flag debt.
Overview
Feature flags let you ship code to production without exposing it to all users at once. You control which users see which features at runtime — no redeployment required.
Progressive rollouts extend this: instead of flipping a flag for everyone simultaneously, you release to 1% of users, then 10%, then 100% — watching metrics at each stage and rolling back instantly if something breaks.
Together, these patterns decouple deployment from release, which is one of the highest-leverage practices in continuous delivery.
How It Works
A feature flag is a conditional:
if (flags.newCheckout) {
return <NewCheckout />;
} else {
return <LegacyCheckout />;
}The flag value comes from an external source — not hardcoded — so it can be changed without redeployment.
Flag Taxonomy
Not all flags are the same. Keeping them categorised prevents flag sprawl:
| Type | Purpose | Lifetime |
|---|---|---|
| Kill switch | Emergency disable of a broken feature | Until bug is fixed |
| Release flag | Gate a feature in dev, enable on rollout | Removed after 100% rollout |
| Experiment flag | A/B test — two variants measured against a metric | Until winner declared |
| Ops flag | Toggle expensive behaviour (prefetching, background jobs) | Indefinite |
| Permission flag | Enable for specific users (beta, employees, paid tier) | Business lifetime |
Consistent Bucketing
For progressive rollouts to produce stable experiences, the same user must always land in the same bucket. The standard algorithm:
hash(userId + flagName) % 100 < rolloutPercentage → flag ONIncluding flagName in the hash prevents all flags from turning on for the same users at the same time (correlation bias).
Where to Evaluate Flags
| Location | When to use |
|---|---|
| Middleware | Full-page redirects, locale routing, A/B test assignment at the edge |
| Server Component (RSC) | Branch between different component trees without client flicker |
| Server Action | Gate which action path executes |
| API Route Handler | Gate API response shape or behaviour |
| Client Component (prop) | Pass pre-evaluated flag value as prop — never fetch flags from a Client Component |
Code Examples
1. Flag Resolver with Consistent Hashing
// lib/flags.ts
import { get } from "@vercel/edge-config";
import { cookies } from "next/headers";
export type Flags = {
newCheckout: boolean;
aiRecommendations: boolean;
redesignedNav: boolean;
};
export async function getFeatureFlags(): Promise<Flags> {
const cookieStore = await cookies();
const userId = cookieStore.get("userId")?.value ?? "anonymous";
// Edge Config is a sub-millisecond key-value store — ideal for flags
const rollouts = (await get<Record<string, number>>("rollouts")) ?? {};
return {
newCheckout: isInRollout(userId, "newCheckout", rollouts.newCheckout ?? 0),
aiRecommendations: isInRollout(
userId,
"aiRecommendations",
rollouts.aiRecommendations ?? 0,
),
redesignedNav: isInRollout(
userId,
"redesignedNav",
rollouts.redesignedNav ?? 0,
),
};
}
/**
* Consistent user bucketing:
* Same user + same flag always lands in the same bucket (stable experiences).
* Including flagName in the hash prevents correlation across flags.
*/
function isInRollout(
userId: string,
flagName: string,
percentage: number,
): boolean {
if (percentage >= 100) return true;
if (percentage <= 0) return false;
// Simple but stable hash — use a proper hash function (FNV-1a, djb2) for production
const seed = `${userId}:${flagName}`;
const hash = [...seed].reduce(
(acc, ch) => (acc * 31 + ch.charCodeAt(0)) & 0xffff,
0,
);
return hash % 100 < percentage;
}2. Server Component — Branch Component Trees
// app/checkout/page.tsx
import { getFeatureFlags } from "@/lib/flags";
import { NewCheckout } from "@/components/new-checkout";
import { LegacyCheckout } from "@/components/legacy-checkout";
export default async function CheckoutPage() {
// Evaluated server-side — the correct branch is server-rendered
// No client flicker, no flag value in the client bundle
const flags = await getFeatureFlags();
return flags.newCheckout ? <NewCheckout /> : <LegacyCheckout />;
}3. Middleware — Edge A/B Test Assignment
Middleware runs at the edge before any rendering — ideal for A/B test assignment and full-page routing:
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { get } from "@vercel/edge-config";
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// A/B test: route 50% of /pricing traffic to /pricing-v2
if (pathname === "/pricing") {
const rollout = (await get<number>("pricingV2Rollout")) ?? 0;
const userId = request.cookies.get("userId")?.value ?? crypto.randomUUID();
const hash = [...`${userId}:pricingV2`].reduce(
(acc, ch) => (acc * 31 + ch.charCodeAt(0)) & 0xffff,
0,
);
const inVariant = hash % 100 < rollout;
if (inVariant) {
const url = request.nextUrl.clone();
url.pathname = "/pricing-v2";
return NextResponse.rewrite(url);
}
}
// Kill switch: block a feature completely if it's broken
if (pathname.startsWith("/dashboard/analytics")) {
const enabled = (await get<boolean>("analyticsEnabled")) ?? true;
if (!enabled) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
}
return NextResponse.next();
}
export const config = {
// Never intercept static files, API routes, or _next internals
matcher: ["/pricing", "/dashboard/analytics/:path*"],
};4. Passing Flags to Client Components
Never evaluate flags inside a Client Component — pass them as props from a Server Component:
// app/product/[id]/page.tsx — Server Component
import { getFeatureFlags } from "@/lib/flags";
import { ProductPageClient } from "@/components/product-page";
export default async function Page({ params }: { params: { id: string } }) {
const flags = await getFeatureFlags();
return (
<ProductPageClient
productId={params.id}
showAiRecommendations={flags.aiRecommendations}
/>
);
}// components/product-page.tsx
"use client";
import { AiRecommendations } from "./ai-recommendations";
type Props = {
productId: string;
showAiRecommendations: boolean;
};
export function ProductPageClient({ productId, showAiRecommendations }: Props) {
return (
<div>
{showAiRecommendations && <AiRecommendations productId={productId} />}
</div>
);
}5. Anonymous User Handling
Users without a session still need stable bucketing:
// middleware.ts — assign a stable anonymous ID if none exists
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
if (!request.cookies.get("userId")) {
const anonymousId = crypto.randomUUID();
// Persist for 1 year — stable across sessions
response.cookies.set("userId", anonymousId, {
maxAge: 60 * 60 * 24 * 365,
httpOnly: true,
sameSite: "lax",
});
}
return response;
}6. Flag Lifecycle Governance — CI Lint Rule
Enforce flag cleanup by expiring stale flags in CI:
// scripts/check-flag-expiry.ts
import { flags } from "@/lib/flag-registry";
const MAX_AGE_DAYS = 90;
for (const [name, meta] of Object.entries(flags)) {
const ageMs = Date.now() - new Date(meta.createdAt).getTime();
const ageDays = ageMs / (1000 * 60 * 60 * 24);
if (ageDays > MAX_AGE_DAYS && meta.type === "release") {
console.error(
`Flag "${name}" is ${Math.floor(ageDays)} days old. ` +
`Release flags must be removed within ${MAX_AGE_DAYS} days of creation.`,
);
process.exit(1); // Fail CI
}
}Real-World Use Case
An e-commerce platform rolls out a redesigned checkout. The old checkout has been stable for two years — a bad release could cost significant revenue.
Rollout sequence: (1) ship behind newCheckout flag at 0% — "dark launch", code is in production but zero users see it; (2) enable for internal employees (100% for isEmployee === true); (3) bump to 5%, monitor conversion and error rate for 24 hours; (4) bump to 25%, 50%, 100% in daily increments; (5) at 100% for 1 week with no regressions, remove the flag and the legacy component. Each stage can be rolled back in seconds by updating Edge Config — no redeployment.
Common Mistakes / Gotchas
1. Fetching flags in Client Components. This causes a flash of the old experience on page load — the server renders without the flag, the client fetches it, and the UI swaps. Always evaluate flags in Server Components or middleware.
2. Non-sticky bucketing. Using Math.random() or any non-deterministic method means the same user gets different experiences on each request. Always hash a stable identifier.
3. Not including the flag name in the hash. If you hash only userId % 100, all flags share the same bucket distribution. A user at the 4th percentile is in the first 5% rollout of every flag simultaneously — creating correlation bias in experiments.
4. Leaking rollout configuration to the client bundle. Importing your full flag config (including percentages and targeting rules) in a Client Component ships internal strategy to the browser. Keep flag evaluation server-only.
5. Stale flags never cleaned up. A codebase with 60 active flags becomes unreadable. Treat flag removal as part of the feature's definition of done. Use a registry with createdAt timestamps and a CI lint rule to enforce cleanup.
Summary
Feature flags decouple deployment from release — code ships to production on every merge, but features are controlled at runtime without redeployment. Consistent bucketing (hash of userId + flagName modulo 100) ensures stable experiences. Evaluate flags in Server Components or middleware — never in Client Components — to avoid flicker and keep targeting logic server-side. Assign anonymous users a stable cookie-based ID so they get consistent experiences before authentication. Maintain a flag registry with type classification and creation dates; enforce cleanup with a CI lint rule. Progressive rollouts follow a sequence: dark launch → internal → 5% → 25% → 50% → 100% → cleanup.
Interview Questions
Q1. What is the difference between a kill switch, a release flag, and an experiment flag?
A kill switch is an operational safety valve — it disables a feature that is actively causing problems. It exists indefinitely, is typically boolean, and should be flippable in under 30 seconds without a deployment. A release flag gates a new feature during its rollout period: the feature is in production but disabled; you increment the rollout percentage as confidence grows; the flag is removed once the feature reaches 100% and has been stable for a period. Its lifetime is weeks, not months. An experiment flag creates two distinct code paths for an A/B test — the experiment is tied to a measured outcome (conversion rate, engagement). Once the winning variant is declared, the losing variant's code is deleted and the flag is removed. Using kill switches as experiment flags (or release flags as kill switches) creates confusion about ownership, lifetime, and cleanup responsibility.
Q2. Why must the flag name be included in the consistent bucketing hash, and what goes wrong if it isn't?
If you hash only userId % 100, every flag's bucket distribution is identical. A user at the 3rd percentile is in the first 5% rollout for every flag you ever release simultaneously — their experience is always the earliest adopter group. In an A/B test context this is correlation bias: users in one experiment are disproportionately likely to be in another, contaminating both. Including the flag name (hash(userId + flagName) % 100) produces independent distributions per flag. The same user might be in the 3rd percentile for newCheckout but the 74th percentile for aiRecommendations — each flag has its own randomised but stable assignment. This independence is necessary for experiment validity.
Q3. Why should flags never be evaluated inside a Client Component, and what is the correct pattern?
Client Components render in the browser after the initial server render. If flag evaluation happens client-side — via a useEffect fetch or a client-side SDK call — the initial server render produces the old (control) experience, and the component re-renders after the flag value arrives. This creates a visible "flash" where the old UI appears briefly before being replaced by the new one. It also ships flag targeting logic (rollout percentages, user segment rules) to the browser where users can inspect and manipulate it. The correct pattern: evaluate flags in a Server Component or in middleware during the request, then pass the pre-resolved boolean as a prop to any Client Components that need it. The server renders the correct variant immediately; no flicker; no targeting logic in the bundle.
Q4. What is a "dark launch" and when is it useful?
A dark launch (also called a "shadow rollout") means deploying new code to production with the feature flag set to 0% — zero users see it. The code path exists and runs in the production environment, but no traffic reaches it via the flag conditional. This is useful for: validating that the new code doesn't crash on import (JavaScript module-level errors surface immediately), warming up any caches or services the new feature depends on, and allowing integration tests to exercise the new code path against production infrastructure by passing a special test cookie or header that bypasses the 0% gate. A dark launch separates "does the code exist in production without breaking anything?" from "are users seeing the feature?" — catching deployment issues before any user-facing impact.
Q5. How should anonymous (unauthenticated) users be handled for consistent bucketing?
Without a user ID, every page load produces a different hash result — the same visitor gets different experiences on each visit, which is confusing and corrupts experiment metrics. The solution is to assign a stable anonymous identifier on the user's first visit. Middleware intercepts the first request, checks for a userId cookie, and if absent, generates a crypto.randomUUID() and sets it as an httpOnly, SameSite=Lax cookie with a long TTL (1 year). All subsequent requests carry this cookie, and flag evaluation uses it as the stable bucketing key. On authentication, the anonymous ID should either be preserved (to maintain consistent experiment assignment) or mapped to the authenticated user ID — depending on whether your analytics pipeline needs to stitch anonymous and authenticated event streams.
Q6. What is flag debt and how do you prevent it from accumulating?
Flag debt is the accumulation of stale feature flags in a codebase — flags that reached 100% rollout but were never cleaned up, experiments that concluded but whose losing-variant code remains, and kill switches for features that no longer need emergency disabling. Each live flag represents a conditional branch that every engineer must reason about when reading or modifying the affected code. With 50 flags, core components have deeply nested conditionals and it becomes unclear which code path is "canonical." Prevention strategies: (1) require every flag to be registered in a central registry with type, createdAt, owner, and expectedRemovalDate; (2) add a CI lint rule that fails the build if a release flag is older than N days; (3) include flag removal as part of the feature's definition of done — it's not shipped until the flag is gone and the legacy code path is deleted; (4) in sprint planning, budget time for flag cleanup as technical debt work.
CI/CD Pipelines for Frontend
A complete frontend CI/CD pipeline — lint, typecheck, unit tests, matrix Node testing, bundle size gating with size-limit, Lighthouse CI, preview deployments, post-deploy smoke tests, dependency caching, OIDC authentication, and turbo remote caching for monorepos.
Error Tracking & Observability
Integrating Sentry in Next.js App Router — source map upload and deletion, error.digest server-client correlation, beforeSend noise filtering, error fingerprinting, structured JSON logging, session replay sampling, alerting on user impact vs event volume, and the difference between error rate, error volume, and affected users.