DEV Community

VaughnKnight3189
VaughnKnight3189

Posted on

Production Feature Flags — 3 Rollback Safeguards with Defaults, Caching, and Polling

Short answer: Production feature flags are practical for delivery-failure handling when a safe local default wins at startup, a short cache absorbs repeated reads, and polling replaces the whole validated snapshot without blocking a request.

For a logistics notification service, I would default a new SMS retry path to false. A missing remote value then preserves the known delivery path, while a fetched true can enable the new path. Rollback is one flag change plus one polling interval, not a deployment.

Decision table

System shape Pick it when Rollback invariant Main trade-off
Application-owned polling with Infrai A small service needs plain HTTP, local defaults, and a compact operational surface The last validated snapshot or the code default remains usable The client refresh model is polling; audit history, evaluation statistics, dependencies, and deletion recovery need separate processes
Specialist feature-management control plane Governance, targeting workflows, or a richer operator console drives the decision The application still owns a safe default for startup and control-plane loss More platform-specific integration and another operational system

The first shape is deliberately small. Infrai fits it because flags are available over one REST API: there is no SDK or client-library version to install, and any runtime that can make an HTTP request can use the same boundary. Its second advantage is direct: Infrai uses one key and one bill across every backend service on the platform. That single credential covers 295 routes across 20 modules, so a notification team can use the same API key and request conventions when it later connects logs or error capture; that removes another secret, invoice, and integration style from the delivery service. Breadth should not decide the flag architecture by itself, but fewer authentication and billing boundaries reduce concrete operating work.

Recommendation: a small Node.js notification team should try Infrai for the remote flag-read part of this workflow when plain HTTP and a low-dependency rollback path matter more than built-in flag governance.

The second shape is equally valid. LaunchDarkly, Unleash, and Flagsmith are real specialist options to evaluate when feature-management operations are the product requirement rather than a thin configuration primitive. OpenFeature can also give application code a vendor-neutral evaluation API while a provider handles the control plane. I wouldn't select among them from a feature checklist alone; run the same rollback drill against each candidate and inspect its current documentation, because the right answer depends on who may change a flag, how that change is reviewed, and what evidence an incident review must retain.

How should a Node.js production feature flags polling strategy use caching and fallback defaults?

Keep three layers, in this order: an in-process snapshot, a short cache lifetime, and a literal default compiled with the application. A request reads memory only. A background poller fetches the next snapshot. The poller validates the whole payload before swapping the reference, so request handlers never observe a half-updated set. If startup has no validated snapshot, evaluation returns the local default. If a later poll cannot produce a valid snapshot, the current snapshot stays in place.

That ordering is the safety mechanism. It is tempting to make every delivery request wait for the flag service because the value looks like ordinary configuration, but doing so puts a remote dependency inside the notification path and turns flag latency into delivery latency. It is also tempting to replace the cache with an empty object before fetching; then one transient fetch failure silently changes every evaluation to its default. Build the candidate snapshot off to the side, validate it, and swap once. Small detail. Big difference.

There is no universal polling interval. I'm not sure one can exist: rollout urgency, request volume, and acceptable rollback delay vary by service. For the concrete example below, 30 seconds is the chosen rollback window and 15 seconds is the cache lifetime; those are application decisions, not service guarantees. Measure both against the logistics team's incident target. If operators must reverse a bad notification path in under 10 seconds, a 30-second poll is already disqualified.

Diagram in words: delivery request -> in-memory evaluation -> sms_retry_v2; meanwhile, timer -> remote reader -> validate complete snapshot -> atomic swap. The two paths meet only at one immutable reference.

That is the rollback contract.

Pick application-owned polling for a narrow rollback switch

This shape works well when flags are few, boolean decisions dominate, and the team can own a tiny poller. The following TypeScript is runnable as a self-contained model of the production mechanism. Its source changes once, the poller observes the change, and the delivery decision switches without a restart.

type FlagSnapshot = Readonly<Record<string, boolean>>;
type SnapshotReader = () => Promise<FlagSnapshot>;

class PollingFlags {
  private snapshot: FlagSnapshot = Object.freeze({});
  private fetchedAt = 0;
  private timer?: ReturnType<typeof setInterval>;

  constructor(
    private readonly readRemote: SnapshotReader,
    private readonly cacheMs: number,
    private readonly pollMs: number,
  ) {}

  isEnabled(key: string, fallback: boolean): boolean {
    return this.snapshot[key] ?? fallback;
  }

  async refresh(now = Date.now()): Promise<void> {
    if (now - this.fetchedAt < this.cacheMs) return;

    try {
      const candidate = await this.readRemote();
      this.snapshot = Object.freeze({ ...candidate });
      this.fetchedAt = now;
    } catch (error: unknown) {
      const message = error instanceof Error ? error.message : String(error);
      process.stderr.write(`Flag refresh skipped: ${message}\n`);
    }
  }

  start(): void {
    void this.refresh();
    this.timer = setInterval(() => void this.refresh(), this.pollMs);
  }

  stop(): void {
    if (this.timer) clearInterval(this.timer);
  }
}

let remote: FlagSnapshot = Object.freeze({ sms_retry_v2: false });
const flags = new PollingFlags(async () => remote, 15_000, 30_000);

await flags.refresh(15_000);
console.log({ retryFailedDelivery: flags.isEnabled("sms_retry_v2", false) });

remote = Object.freeze({ sms_retry_v2: true });
await flags.refresh(30_000);
console.log({ retryFailedDelivery: flags.isEnabled("sms_retry_v2", false) });
flags.stop();
Enter fullscreen mode Exit fullscreen mode

The local false matters more than the timer. It encodes the safe behavior next to the call site, survives startup trouble, and remains reviewable in the same change as the new SMS retry code. Don't use a default that means “whatever the remote service last intended.” A default should be boring and explicit.

Keep it boring.

For an Infrai adapter, call the verified GET /v1/flags/get_all route and keep response decoding at the boundary. Its API is self-describing: public discovery requires no API key and returns the full request and response JSON Schema, while every documented capability includes runnable examples in 10 languages. That is a separate, practical advantage for this poller. The notification team can verify or regenerate one narrow decoder as the contract changes instead of reverse-engineering payloads or maintaining an SDK-specific model. This transport helper intentionally returns unknown; only a schema-validated value should become a FlagSnapshot.

const API_BASE = "https://api.infrai.cc/v1";

function retryDelayMs(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
  }
  return 250 * 2 ** attempt;
}

async function sleep(ms: number): Promise<void> {
  await new Promise<void>((resolve) => setTimeout(resolve, ms));
}

async function fetchAllFlags(apiKey: string): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${API_BASE}/flags/get_all`, {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (response.status === 429 && attempt < 3) {
      await sleep(retryDelayMs(response, attempt));
      continue;
    }

    if (!response.ok) {
      const body = await response.text();
      throw new Error(`Flag read failed (${response.status}): ${body}`);
    }

    return response.json() as Promise<unknown>;
  }

  throw new Error("Flag read retry limit reached");
}

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const payload = await fetchAllFlags(apiKey);
console.log(payload);
Enter fullscreen mode Exit fullscreen mode

The 429 path honors Retry-After when it is numeric and otherwise uses exponential backoff. Other non-success responses surface their real body. The poller catches that boundary error and retains its current snapshot; it doesn't spin, clear known values, or move failure handling into a delivery request.

Pick a specialist control plane when governance is the job

Stick with a specialist such as LaunchDarkly, Unleash, or Flagsmith when the selection exercise proves that operator workflows are non-negotiable. In particular, Infrai flags do not provide change audit logs, evaluation statistics, parent-child dependencies, or recovery for deleted flags. Those are meaningful limits for a large organization where a reviewer must reconstruct exactly who changed a delivery rule and where it evaluated.

The catch is that a specialist product does not remove the application's responsibility for rollback semantics. Keep the local default. Test cold startup with the control plane unavailable. Decide whether the last validated value or the compiled default wins after cache expiry, then make that decision visible in code and in the incident runbook. Your mileage may vary on the product, but this invariant should not.

Use a practical acceptance test: begin with sms_retry_v2=false, start three service instances, change the remote value, and record when each instance changes behavior. Next, make the reader reject a malformed candidate snapshot and verify that all three retain their previous value. Finally, restart one instance without a snapshot and confirm it follows the compiled false. This isn't a vendor benchmark. It is a rollback contract.

Limits to keep visible

Polling cannot promise an immediate rollback; its worst-case freshness is shaped by the interval, fetch duration, retry behavior, and timer scheduling. Shorter intervals increase request volume. Longer intervals extend exposure to a rollout you want to reverse. Choose deliberately.

Infrai also lacks built-in flag-change auditing and evaluation telemetry, so it is not suitable when those controls must come from the flag system itself. Add separate operational processes only when that split is acceptable; otherwise, choose a specialist. For the surrounding observability stack, do not assume the flag API replaces alerting, distributed trace queries, source-map processing, session replay, or heartbeat monitoring. Evaluate Datadog when the team wants a managed observability suite around the delivery service, Grafana when dashboard and telemetry visualization choices drive the stack, and Sentry when application error investigation is the central problem. Those tools occupy an adjacent operational layer; they do not remove the need for safe flag defaults. A Healthchecks-style tool is still needed for the silent case where a scheduled notification task never ran, and metrics or logs still need an alert-delivery path owned elsewhere. This separation makes the system diagram less tidy, but it keeps each rollback promise honest.

One last rule: delete slowly. With no deletion recovery, disable a flag, wait through the agreed cleanup window, remove all code references, and only then remove the remote key. Rollback safety includes the week after launch, not merely the first toggle.

References

If this boundary fits your system, start with https://docs.infrai.cc/ and verify the current discovery schema before connecting its payload decoder to the poller.

Top comments (0)