Efficient Feature Flag Rollouts with Delta Deploys and Observability
Efficient Feature Flag Rollouts with Delta Deploys and Observability
Feature flags are a powerful tool for releasing code safely, testing experiments, and enabling gradual rollouts. But without disciplined patterns, flags become a tangle of cruft that slows your team and hides bugs. This tutorial walks you through a practical, end-to-end approach to building, deploying, and observing feature flags with minimal risk. You’ll get concrete code examples, recommended abstractions, and a step-by-step workflow you can adapt to teams of varying sizes.
Why a disciplined feature flag strategy matters
- Flags decouple deployments from releases, enabling safer shipping and faster iterations.
- A chaotic flag surface increases cognitive load, causes drift, and introduces regression risk.
- Delta deploys (deploys that include flag wiring but gradually enable features) plus robust observability reduce blast radius and accelerate rollback if needed.
Key ideas:
- Treat flags as product features with lifecycle, owners, and metrics.
- Use delta deploys to ship code incrementally, then flip flags in small, verifiable steps.
-
Instrument flags with observability to answer: who toggled what, when, and why.
High-level architecture
Feature flag server or service: stores flag definitions, strategies, and runtime values.
Client SDK: fetches flags and evaluates them locally or remotely.
Release pipeline: deploys code with flags, but only enables features after checks.
Observability: dashboards, traces, and event logs that correlate flag activity with system health.
Common patterns:
- Centralized flag service with per-environment namespaces.
- Client-side evaluation for fast paths (web/mobile), server-side evaluation for secure toggles.
- Delta gates: a staged activation plan that moves from dark launches to incremental exposure. ### Step 1: Define flag lifecycle and ownership
Before writing code, agree on a flag lifecycle and who owns each flag.
Flag lifecycle stages:
- Planned: flag exists in code but not active.
- Experimental: limited audience; metrics collected.
- Enabled (beta): broader rollout with safety guards.
- Fully on/Off: stable for production use or rolled back.
- Deprecated: removed from codebase after retirement.
Owners:
- Assign a product owner and a stability/metrics owner for each flag.
- Document expected metrics and success criteria.
Example lifecycle in a small table (conceptual):
- Flag: "new-dashboard-landing"
- Stage: Experimental
- Owner: Product-Team-A
- Rollout target: 5% in prod
- Success criteria: 0.01% error rate, +3% engagement ### Step 2: Implement a minimal feature flag system (code first)
We’ll implement a lightweight in-process flag system with a remote flag store concept. This lets us ship code with flags, then flip them using a simple API.
Assumptions:
- Environment: Node.js (TypeScript) server.
- Flag definitions live in code, with a remote override endpoint for flips.
- We’ll simulate a central flag store with an in-memory map and a tiny HTTP endpoint to update flags during rollout.
Code: minimal flag client and a sample server
- src/flags.ts
- src/flagStore.ts
- src/api.ts
- src/server.ts
Key concepts:
- Flag type: name, defaultValue, rollout (percentage or list of enablement criteria).
- Evaluator: resolves current value based on environment and rollout rules.
- Refresh: periodically pull remote changes (polling) or support WebSocket push.
src/flags.ts
- Exports a Flag type and a getFlagVal(name, context) function.
src/flagStore.ts
- In-memory store plus a method to merge remote updates.
src/api.ts
- Simple REST endpoints to get and patch flag definitions (for rollout tooling).
src/server.ts
- Express app that uses getFlagVal to serve responses with feature-flag-dependent behavior.
Example snippet (TypeScript-ish, compact):
// src/flags.ts
export type Flag = {
name: string;
defaultValue: T;
rollout?: (env: string, context?: any) => boolean;
};
type FlagRegistry = Record>;
const registry: FlagRegistry = {};
export function registerFlag(flag: Flag) {
registry[flag.name] = flag;
}
export function getFlagVal(name: string, context?: any, env: string = process.env.NODE_ENV || "production"): T {
const f = registry[name];
if (!f) {
throw new Error(Unknown flag: ${name});
}
if (f.rollout) {
// Rollout function can use env, context, or user id to decide
return (f.rollout(env, context) ? f.defaultValue : toggleValue(f.defaultValue)) as T;
}
return f.defaultValue;
}
// small helper to invert value or provide a deterministic fallback
function toggleValue(v: T): T {
if (typeof v === "boolean") return (!v) as unknown as T;
// for non-boolean flags, implement a simple fallback (e.g., no toggle)
return v;
}
// src/flagStore.ts
type RemoteFlagUpdate = { name: string; rollout?: (env: string, ctx?: any) => boolean; defaultValue?: any };
const store: Record> = {};
export function setFlag(flag: Flag) {
store[flag.name] = flag;
// In a full impl, notify listeners
}
export function applyRemoteUpdate(update: RemoteFlagUpdate) {
const existing = store[update.name];
if (existing) {
if (update.rollout) existing.rollout = update.rollout;
if (update.defaultValue !== undefined) existing.defaultValue = update.defaultValue;
} else if (update.defaultValue !== undefined) {
// Create a new flag on the fly
store[update.name] = { name: update.name, defaultValue: update.defaultValue, rollout: update.rollout };
}
}
// src/api.ts
import express from "express";
import { setFlag } from "./flagStore";
const router = express.Router();
router.get("/flags", (req, res) => {
// return a simple snapshot; in real life, send serialized flags
res.json({ ok: true, flags: Object.keys(store) });
});
router.post("/flags/update", (req, res) => {
const upd = req.body as { name: string; defaultValue?: any; rollout?: string };
// In a real system, rollout would be a complex rule; here we mock with a boolean toggle
if (upd.name) {
setFlag({ name: upd.name, defaultValue: upd.defaultValue ?? true });
res.json({ ok: true, updated: upd.name });
} else {
res.status(400).json({ error: "name required" });
}
});
export default router;
- src/server.ts import express from "express"; import { getFlagVal } from "./flags"; import flagRoutes from "./api";
const app = express();
app.use(express.json());
app.use("/api", flagRoutes);
app.get("/protected", (req, res) => {
const showNew = getFlagVal("new-dashboard", { user: req.query.user }, "production");
if (showNew) {
res.send("New dashboard enabled");
} else {
res.send("Classic dashboard");
}
});
app.listen(3000, () => console.log("Server listening on 3000"));
Notes:
- This is a minimal, demonstration structure. In production, you’d separate concerns: a dedicated flag service, remote evaluation rules, robust caching, and secure role-based access to updates. ### Step 3: Enable delta deploys with a staged activation plan
Delta deploys ship code with flags already wired, but no immediate feature exposure. You control exposure in small steps.
A practical rollout plan:
- Dark launch: deploy code with flag on the server-side but keep the feature hidden from users (no visible UI change). Collect internal telemetry to confirm wiring works.
- Canary by user cohort: enable for a small percentage of users in prod. Use a deterministic user-based rollout (e.g., hash(user_id) mod 100 < 5).
- Gradual expansion: increase the percentage weekly; monitor latency, error rates, and user metrics.
- Full rollout: flag consistently evaluated in production with telemetry showing stability.
- Debrief and retire: after a stable period, remove flag code paths if the feature is to be permanent or migrate to a config flag.
Practical example: user-based rollout function
function canaryRollout(userId: string, percentage: number) {
const hash = crc32(userId) >>> 0;
return (hash % 100) < percentage;
}
function crc32(str: string) {
let crc = 0 ^ 0xffffffff;
for (let i = 0; i < str.length; i++) {
const byte = str.charCodeAt(i);
crc = (crc >>> 8) ^ table[(crc ^ byte) & 0xff];
}
return (crc ^ 0xffffffff) >>> 0;
}
// A precomputed CRC table constant omitted for brevity
Then in the flag rollout function:
rollout: (env, ctx) => {
const userId = ctx?.user?.id ?? "anonymous";
return canaryRollout(userId, 10); // 10% for canary
}
This approach gives you deterministic behavior per user and avoids surprises when a feature flips for some users but not others.
Step 4: Observability: track, alert, and learn
Instrumenting feature flags is essential. You want to know who toggled what, when, and how the feature affects system health.
Recommended observability stack:
- Metrics: capture feature exposure counts, latency of feature paths, error rates in the feature code path.
- Tracing: correlate user interactions with flag evaluations and downstream service calls.
- Logging: annotate logs with flag names and values for post-incident analysis.
- Dashboards: a single pane showing rollout progress, performance KPIs, and rollback status.
Concrete instrumentation ideas:
- Emit a metric like feature_flag.deck_usage_total with tags: flag=name, env, user_segment, outcome.
- Add trace annotations or span attributes for flag evaluations.
- Log flag toggles with a standard schema: timestamp, flag, previous_value, new_value, changed_by, reason.
Example pseudo-logging helper:
function logFlagEval(flagName: string, value: any, context: any) {
const log = {
ts: new Date().toISOString(),
flag: flagName,
value,
contextHash: hashContext(context),
};
console.log("flag-eval", JSON.stringify(log));
}
In production, route these logs to a centralized system (e.g., ELK/Prometheus/Grafana, OpenTelemetry).
Step 5: Safe rollback and retirement workflow
- If a set of metrics degrades after rollout, flip the flag off quickly using the same mechanism you used to turn it on.
- Maintain a retirement plan for flags after the feature stabilizes:
- Remove references in code gradually.
- Delete or archive flag definitions from the flag store after a grace period.
- If you keep flags, ensure they have a deprecation path and automated cleanup.
Pro-tip:
- Always implement a hard cap on the maximum percentage exposure for critical features, and require a manual override to exceed it. ### Step 6: Example end-to-end workflow
1) Developer adds a new UI component behind a flag "new-dashboard".
2) Code compiled and deployed with flag wiring; canary rollout starts at 0% (dark launch) in production.
3) Telemetry shows no user exposure yet; internal dashboards verify no performance impact.
4) After validation, enable for 5% of users deterministically.
5) Monitor: metrics show engagement unchanged; log correlations show no errors.
6) Expand gradually to 20%, 50%, then 100% while maintaining guardrails.
7) Once stable, retire the flag and remove code paths.
Example code path in route handler:
app.get("/dashboard", (req, res) => {
const showNew = getFlagVal("new-dashboard", { user: req.user }, "production");
if (showNew) {
res.render("new-dashboard");
} else {
res.render("classic-dashboard");
}
});
This keeps the URL surface stable while toggling features behind the flag.
Step 7: Testing strategies for flagged code
- Unit tests: cover both branches of the flag to ensure correct behavior.
- Integration tests: simulate remote updates to ensure the client picks up changes.
- Performance tests: verify that flag evaluation adds negligible latency.
- Chaos testing: deliberately flip flags in a controlled environment to verify rollback procedures.
Test example (pseudo):
describe("new-dashboard feature", () => {
it("renders new dashboard when flag is enabled", () => {
setFlag({ name: "new-dashboard", defaultValue: true });
const html = renderDashboard({ user: { id: "u1" } }, "production");
expect(html).toContain("New Dashboard");
});
it("renders classic dashboard when flag is disabled", () => {
setFlag({ name: "new-dashboard", defaultValue: false });
const html = renderDashboard({ user: { id: "u1" } }, "production");
expect(html).toContain("Classic Dashboard");
});
});
Step 8: Practical recommendations and pitfalls to avoid
- Don’t treat flags as permanent code. Use them as configurable levers with a lifecycle and retirement plan.
- Avoid coupling flags to critical path logic that would complicate rollback. Keep flip logic simple and isolated.
- Use deterministic rollouts to prevent confusion. Randomized rollouts are fine, but you should be able to reproduce results for a given user.
- Keep a clean flag catalog. Remove stale flags to prevent technical debt.
- Align flag ownership with product goals and ensure clear metrics so you can decide when to retire or expand. ### Minimal starter project (what you can copy)
If you want a quick-start repo, you can scaffold a small Node.js project with:
- A lightweight in-process flag registry (as shown in src/flags.ts).
- An Express server that uses the flag values to decide rendering paths.
- A simple API to update flag definitions during rollout.
- Basic logging to stdout that can feed a log aggregator.
This is intentionally minimal but demonstrates the core workflow: deploy with flags, monitor, and flip exposure in controlled steps.
If you’d like, I can tailor this tutorial to your stack (React, Next.js, backend in Go, etc.), and provide a ready-to-run example with a GitHub Actions workflow for delta deploys. Would you prefer a web frontend (React/Next.js) or a backend service example, and which language stack should I align the code with?
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)