DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Efficient Feature Flag Rollouts with Delta Deploys and Observability

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:

  1. 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.
  2. 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).
  3. Gradual expansion: increase the percentage weekly; monitor latency, error rates, and user metrics.
  4. Full rollout: flag consistently evaluated in production with telemetry showing stability.
  5. 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)