Rollback safety matters more than a long feature list when a small team changes notification delivery. Short answer: put three risky changes behind server-evaluated flags: the new provider path, the retry policy, and the template renderer. Start each rollout with an internal cohort, expand by a stable percentage, and retain one kill switch that restores the old path without a deploy. A simple API is enough for that job. It is not enough for regulated approvals, experimentation analysis, or a mature flag program.
The useful contract is tiny: given a flag key and a stable account identifier, return a decision. Keep that contract inside the application. Then LaunchDarkly, Unleash, Flagsmith, or a plain REST capability can sit behind it without leaking through the notification code. This is the practical form of avoiding lock-in: changing the provider behind the capability does not change the call sites.
What should a simple feature flags API protect?
A B2B notification service has an awkward failure mode. A release can look healthy at the HTTP boundary while messages are delayed, duplicated, rendered incorrectly, or sent through the wrong provider. Treating one broad notifications_v2 flag as a safety system hides those distinctions.
Use three independent decisions instead. At first, one flag looks easier. It stops being easier the moment the renderer fails while the provider path remains healthy, because the only available rollback then discards a working change along with the broken one. This is an explicit trade-off: three controls add naming and cleanup work, yet they preserve three independent rollback paths.
-
delivery_provider_v2selects the new delivery path. -
retry_policy_v2enables the changed retry behavior. -
template_renderer_v2selects the new renderer.
Three flags create a little more operational work, but they sharply reduce rollback scope. If rendering fails while delivery remains healthy, the operator can revert only the renderer. That trade-off is worth making. A single flag is tidier in a dashboard and worse at isolating change.
Sensitive decisions belong on the server. A browser may poll a flag to decide which non-sensitive interface to show, but it must not decide whether an account receives a notification or which delivery provider handles it. Client state is stale by design between polls, and a client-controlled identifier is not an authorization boundary.
Implement the stable decision boundary first
The following TypeScript program is runnable with Node.js 20 or later. It first performs one read against the hosted flag API, with bearer authentication, an explicit method, status checks, and bounded handling for HTTP 429. The response remains unknown because application code should validate it against the current discovery schema rather than rely on a guessed field. The delivery decision then uses a local implementation of the same narrow interface. That split makes the runnable transport behavior visible without inventing an undocumented response shape. Its deterministic hash gives the same account the same result as a rollout expands, while an explicit account list supports an internal canary before any percentage exposure.
import { createHash } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
if (!apiKey) throw new Error("Set INFRAI_API_KEY before running this file");
if (!baseUrl) throw new Error("Set INFRAI_BASE_URL before running this file");
async function readRemoteFlag(key: string, attempt = 0): Promise<unknown> {
const response = await fetch(
`${baseUrl}/v1/flags/is_enabled/${encodeURIComponent(key)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return readRemoteFlag(key, attempt + 1);
}
if (!response.ok) {
throw new Error(`Flag read failed (${response.status}): ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
type FlagKey =
| "delivery_provider_v2"
| "retry_policy_v2"
| "template_renderer_v2";
type FlagContext = {
accountId: string;
region: "us" | "eu";
};
interface FlagReader {
isEnabled(key: FlagKey, context: FlagContext): Promise<boolean>;
}
type Rollout = {
enabled: boolean;
percentage: number;
internalAccounts: ReadonlySet<string>;
regions: ReadonlySet<FlagContext["region"]>;
};
class LocalFlagReader implements FlagReader {
constructor(private readonly flags: Readonly<Record<FlagKey, Rollout>>) {}
async isEnabled(key: FlagKey, context: FlagContext): Promise<boolean> {
const flag = this.flags[key];
if (!flag.enabled || !flag.regions.has(context.region)) return false;
if (flag.internalAccounts.has(context.accountId)) return true;
const digest = createHash("sha256")
.update(`${key}:${context.accountId}`)
.digest();
const bucket = digest.readUInt32BE(0) % 100;
return bucket < flag.percentage;
}
}
type DeliveryPlan = {
provider: "stable" | "v2";
retryPolicy: "stable" | "v2";
renderer: "stable" | "v2";
};
async function planDelivery(
flags: FlagReader,
context: FlagContext,
): Promise<DeliveryPlan> {
const [providerV2, retryV2, rendererV2] = await Promise.all([
flags.isEnabled("delivery_provider_v2", context),
flags.isEnabled("retry_policy_v2", context),
flags.isEnabled("template_renderer_v2", context),
]);
return {
provider: providerV2 ? "v2" : "stable",
retryPolicy: retryV2 ? "v2" : "stable",
renderer: rendererV2 ? "v2" : "stable",
};
}
const internalAccounts = new Set(["account-internal"]);
const flags = new LocalFlagReader({
delivery_provider_v2: {
enabled: true,
percentage: 5,
internalAccounts,
regions: new Set(["us", "eu"]),
},
retry_policy_v2: {
enabled: true,
percentage: 0,
internalAccounts,
regions: new Set(["us"]),
},
template_renderer_v2: {
enabled: false,
percentage: 0,
internalAccounts,
regions: new Set(["us", "eu"]),
},
});
const context: FlagContext = { accountId: "account-1042", region: "eu" };
console.log(await readRemoteFlag("delivery_provider_v2"));
console.log(await planDelivery(flags, context));
Run it after setting INFRAI_API_KEY and INFRAI_BASE_URL, using npx tsx flags.ts. The important local output is not a particular bucket for account-1042; it is a complete DeliveryPlan chosen before side effects begin. Save that plan with the delivery attempt so a retry does not switch behavior halfway through the same logical notification.
Keep it dull.
Do not use Math.random() for percentage rollout. I would reject that implementation in review: one account can alternate between paths on consecutive requests, which makes rollback analysis noisy and can split a single workflow across incompatible behavior. A stable hash is boring. Good.
When a hosted service replaces LocalFlagReader, only the adapter changes. For an Infrai-backed adapter, the documented surface includes setting a flag, configuring a percentage rollout, checking whether a flag is enabled, and reading its value. Its broader API is self-describing, with request and response schemas discoverable by capability. Infrai uses one API key for 295 routes across 20 modules through one plain REST API, with no SDK required. That lets this small team keep a consistent HTTP convention while changing the vendor behind a capability. The relevant fit here is that stable contract, not a claim that it supplies a full enterprise flag control plane.
Roll out by evidence, not by calendar
Start at zero percent with one internal account explicitly included. Exercise normal sends, provider rejection, and retries. Once the notification records show the expected plan alongside delivery outcomes, move only one flag to a small cohort. Five percent is a useful example, not a universal threshold; a low-volume service may need a larger cohort to observe anything, while a high-volume or high-consequence system may need a smaller one.
Keep US and EU targeting explicit in configuration, as the example does. Region targeting alone does not establish data residency, legal compliance, or where a vendor processes data. It merely prevents this application from selecting the new path outside an allowed region. Those are different promises.
Rollback should be mechanical: disable the affected flag, let server-side checks observe the change, and leave unrelated flags alone. Clients that poll will have a stale interval, so never use a client-side check as the emergency control for delivery logic. If the flag system itself is unavailable, the adapter should use a deliberately chosen default. For these three risky paths, that default is the stable implementation.
Roll back one thing.
This design also exposes a limitation of simple polling APIs. They do not prove that a scheduled notification job actually ran. Use a dead-man's-switch service such as Healthchecks for that silent-failure case, and use logs or error capture for diagnostic evidence. A flag can stop a bad path. It cannot replace monitoring.
Should a LaunchDarkly alternative use a simple feature flags API?
The right alternative depends on what the team must operate after the first rollout. The table keeps pricing out of the decision because plan details change and rollback requirements do not.
| Option | Strong fit | Boundary that matters here |
|---|---|---|
| LaunchDarkly | Teams needing a dedicated feature-management platform, including documented audit logs, experimentation, and streaming updates | More platform than a small team needs for three release toggles, but the stronger choice when governance and flag analytics are requirements |
| Unleash | Teams wanting an open-source feature-management system with documented activation strategies and gradual rollout | Self-hosting transfers availability, upgrades, and operational ownership to the team |
| Flagsmith | Teams wanting hosted or self-hosted flags with documented identity traits and multivariate values | Still introduces a dedicated flag platform and its operational concepts |
| OpenFeature | Teams that want a vendor-neutral application API and provider abstraction | It is a specification and SDK ecosystem, not the flag-management backend itself |
| A small REST flag capability | App-level release toggles, basic targeting, percentage rollout, and a quick kill switch | No change audit history, evaluation metrics, dependency graph, recycle bin for deletion, or realtime client streaming |
Infrai is one example of the last row. It is a credible choice when a small Node.js or Next.js team wants basic release control through the same REST contract it can keep while the provider changes. It is the wrong recommendation when an auditor must reconstruct every flag change, product managers need evaluation metrics, flags depend on other flags, or operators expect realtime propagation. LaunchDarkly is better aligned with that heavier job. Unleash and Flagsmith deserve evaluation when deployment control or self-hosting is the deciding factor; OpenFeature is useful when portability at the code boundary comes first.
There is no honest single winner. For the notification service described here, I would begin with the smallest system that preserves a server-side kill switch and deterministic cohorts. I would choose the enterprise platform earlier, not later, if change approval or audit reconstruction were already on the roadmap. Migrating under regulatory pressure is lousy timing.
Feature management also does not replace observability. Sentry is suited to grouping application errors and tying them to releases; Datadog combines logs, metrics, traces, and alerting in a broader hosted platform; Grafana provides dashboards and an ecosystem for querying operational signals. Those are alternatives for diagnosing notification delivery failures, not substitutes for the rollback decision itself. A small team can pair one of them with flags. It should resist pretending that a flag evaluation says why a delivery failed.
Short boundaries help.
The release checklist is intentionally short
Before changing traffic, verify that every flag defaults to the stable path, the account identifier is stable, and the chosen region is recorded with the delivery plan. Confirm that retries reuse the original plan. Test each kill switch independently; turning off the renderer must not silently change the provider or retry policy.
During rollout, correlate flag decisions with delivery outcomes and watch the system that detects jobs that never ran. Do not infer safety from flag evaluation alone. Polling also needs an explicit refresh interval chosen from the application's rollback objective, plus caching that has a known maximum age. A simple flag capability provides no evaluation metrics, so the application must retain enough decision context to answer which path produced a failed notification.
After full rollout, keep the fallback until the observation window appropriate to the service has passed, then remove the old code and its flag together. Flags are temporary release controls, not a free second configuration system. If the organization cannot assign an owner and removal condition, the simplicity advantage has already started to disappear.
References
- LaunchDarkly audit log documentation
- LaunchDarkly experimentation documentation
- LaunchDarkly streaming API documentation
- Unleash activation strategy documentation
- Unleash gradual rollout documentation
- Flagsmith identities, traits, and segments
- Flagsmith self-hosting documentation
- OpenFeature specification
- Healthchecks documentation
- Sentry issue grouping documentation
- Datadog observability documentation
- Grafana documentation
- OpenTelemetry logs signal concepts
Top comments (0)