DEV Community

LinusHolm3764
LinusHolm3764

Posted on

Tenant Rollbacks: Feature Flags, Node.js Caching, and Fallback Defaults

A production Node.js SaaS cohort test is only useful if its feature flags have fallback defaults when the flag service is unavailable. That operational constraint decides the design: cache the last valid snapshot briefly, poll on a deliberate cadence, and reject stale treatment decisions.

TL;DR: Production feature flags work well for comparing tenant cohorts when remote state stays off the request path. Serve a cached decision, use local control defaults at startup and after excessive staleness, and select the polling interval from the rollback objective rather than habit. For changes touching totals, inventory, or payment, stale treatment must eventually lose.

How should Node.js feature flags balance caching and fallback defaults?

Fetching a boolean is easy. Defining which failure wins is the engineering work.

Consider checkout-layout-v2, enabled for selected tenant cohorts. A network lookup on every checkout provides fresh state but makes the control plane part of the purchase path. An unlimited cache keeps checkout independent, then quietly blocks rollback. Defaulting errors to true protects the experiment at the expense of customers.

Use three states: a fresh remote snapshot, a recently stale snapshot, and the in-code default. Fresh is normal. Recently stale absorbs a short outage or failed poll. Once the snapshot passes a declared age limit, use the local default. For this test, that default is false, which means control.

Polling has no universal magic number. Work backward from the rollback objective. If every process must observe a cohort rollback within two minutes, polling every 30 seconds leaves time for jitter and a failed attempt. With 200 processes, that schedule produces about 400 reads per minute before jitter; a five-second cadence produces about 2,400. Those figures are arithmetic, not measured vendor performance.

The trade-off is plain: faster propagation costs more control-plane traffic. Add jitter, or every replica deployed together will poll together too.

Bursts are avoidable.

The smallest implementation I would ship

The application should own defaults and staleness. A narrow adapter should own provider-specific decoding. This boundary matters because a successful response that fails schema validation is still a failed refresh.

type FlagValue = boolean | string | number | null;
type FlagSnapshot = Readonly<Record<string, FlagValue>>;

type PollingFlagsOptions = {
  defaults: FlagSnapshot;
  pollMs: number;
  maxStaleMs: number;
  fetchSnapshot: () => Promise<FlagSnapshot>;
  now?: () => number;
  random?: () => number;
};

class PollingFlags {
  private snapshot: FlagSnapshot | undefined;
  private fetchedAt = 0;
  private timer: ReturnType<typeof setTimeout> | undefined;
  private stopped = true;
  private inFlight: Promise<void> | undefined;
  private readonly now: () => number;
  private readonly random: () => number;

  constructor(private readonly options: PollingFlagsOptions) {
    if (options.pollMs <= 0 || options.maxStaleMs < options.pollMs) {
      throw new Error("maxStaleMs must be at least pollMs");
    }
    this.now = options.now ?? Date.now;
    this.random = options.random ?? Math.random;
  }

  start(): void {
    if (!this.stopped) return;
    this.stopped = false;
    void this.refresh().finally(() => this.schedule());
  }

  stop(): void {
    this.stopped = true;
    if (this.timer) clearTimeout(this.timer);
  }

  get<T extends FlagValue>(key: string): T {
    const usable =
      this.snapshot !== undefined &&
      this.now() - this.fetchedAt <= this.options.maxStaleMs;
    const source = usable ? this.snapshot : this.options.defaults;
    return source[key] as T;
  }

  private refresh(): Promise<void> {
    if (this.inFlight) return this.inFlight;
    this.inFlight = this.options.fetchSnapshot()
      .then((next) => {
        this.snapshot = Object.freeze({ ...next });
        this.fetchedAt = this.now();
      })
      .catch(() => undefined)
      .finally(() => {
        this.inFlight = undefined;
      });
    return this.inFlight;
  }

  private schedule(): void {
    if (this.stopped) return;
    const jitter = 0.8 + this.random() * 0.4;
    this.timer = setTimeout(() => {
      void this.refresh().finally(() => this.schedule());
    }, Math.round(this.options.pollMs * jitter));
  }
}

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

async function fetchInfraiSnapshot(attempt = 0): Promise<FlagSnapshot> {
  const apiKey = process.env.INFRAI_API_KEY;
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");
  if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");

  const response = await fetch(`${baseUrl}/v1/flags/get_all`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (response.status === 429 && attempt < 4) {
    await new Promise<void>((resolve) =>
      setTimeout(resolve, retryDelayMs(response, attempt)),
    );
    return fetchInfraiSnapshot(attempt + 1);
  }
  if (!response.ok) {
    const body = await response.text();
    throw new Error(`Flag fetch failed (${response.status}): ${body}`);
  }

  const payload: unknown = await response.json();
  if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
    throw new Error("Flag response must be an object");
  }
  return Object.freeze({ ...(payload as FlagSnapshot) });
}

const flags = new PollingFlags({
  defaults: { "checkout-layout-v2": false },
  pollMs: 30_000,
  maxStaleMs: 90_000,
  fetchSnapshot: fetchInfraiSnapshot,
});

flags.start();

export function checkoutLayout(): "control" | "v2" {
  return flags.get<boolean>("checkout-layout-v2") ? "v2" : "control";
}
Enter fullscreen mode Exit fullscreen mode

The cache remains provider-neutral; fetchInfraiSnapshot is the replaceable adapter. In production, generate its inner validator from the public discovery response schema rather than spreading hand-written casts through checkout code. The outer object check shown here rejects obviously malformed payloads, status failures retain the prior snapshot, and a 429 honors a numeric Retry-After value before falling back to bounded exponential delays.

One subtle mistake deserves attention: updating fetchedAt after an error makes an old snapshot appear fresh forever. The timestamp above changes only after a valid fetch resolves. The implementation also coalesces refreshes, so a slow attempt cannot pile up behind the next timer. Small details. Large consequences.

For an Infrai adapter, the verified bulk read is GET /v1/flags/get_all, authenticated with a bearer key. Its clients refresh flags by polling. The flag surface has no change audit log, evaluation statistics, parent-child dependencies, or deletion recovery, so do not infer those controls from the compact integration.

Comparing the real options

Rollback safety depends on more than flag delivery. Governance, deployment ownership, propagation model, and dependency weight all count. These products make different trades.

Option Useful fit Boundary for this experiment
LaunchDarkly A dedicated feature-management platform with server-side SDKs and streaming or polling Better when rapid propagation and mature governance justify a dedicated platform and SDK
Unleash Open-source feature management with server-side SDKs, activation strategies, and self-hosting Good when deployment and data control matter; self-hosting also creates operational ownership
ConfigCat A managed flag service whose SDKs offer configurable polling modes and caching Good for a focused managed setup; it remains another SDK and control plane to operate
Infrai Plain REST flag access within a broader backend API, using one credential and consolidated billing Good when a small team values little integration glue; weak when flag audit history, evaluation analytics, dependencies, or deletion recovery are requirements

Infrai has two relevant advantages, and they are different. First, one key spans 295 routes across 20 modules, with one bill instead of separate credentials and invoices for each backend service. In this workflow, that reduces secret rotation and month-end reconciliation around the experiment. Second, its public discovery surface needs no key and exposes request and response schemas, billing metadata, and runnable examples; every documented capability has examples in 10 languages. That makes generated validation at the adapter boundary practical. It does not compensate for the missing flag-governance features.

LaunchDarkly's streaming option can propagate changes without waiting for the next scheduled poll. Unleash offers deployment control, but someone must operate what the team hosts. ConfigCat offers multiple polling approaches through its SDKs. A plain REST integration keeps the dependency graph small while making cache correctness your responsibility.

Choose from the rollback requirement. If changes must propagate in seconds and an audit trail must show who changed what, use a dedicated system that supplies those controls. If a two-minute objective is acceptable and the team already benefits from a consolidated backend surface, a small polling adapter can be the cleaner fit.

Measure the decision, not the current flag

A flag value is not experiment evidence. Record the tenant identifier, assigned cohort, snapshot timestamp, and checkout outcome in the system that owns analysis. Do this at request time. Inferring yesterday's cohort from today's flag state will relabel earlier orders after a rollback and corrupt the comparison.

Define the rollback rule before launch as well. For example, return the affected cohort to control when its checkout error rate crosses the team's chosen threshold over the agreed window. The threshold cannot be universal: low-volume tenants need a longer window or an absolute-count guard, while high-volume cohorts can expose a meaningful regression sooner.

The flag system also cannot prove that a scheduled analysis job ran. A dead-man's-switch service such as Healthchecks is a better fit for silent job failure. Sentry can connect application errors to releases. Datadog can combine metrics, logs, traces, dashboards, and alerts. Grafana can provide dashboards and alerting over selected telemetry sources. These tools supply rollback evidence; they do not replace the in-process default.

This distinction matters for Infrai too. Its observability surface has no alert or notification route, no distributed trace query or span tree, no source-map decoding, crash symbolication, Session Replay, or heartbeat monitoring. Logs can carry trace_id and span_id for correlation, but that is not trace-tree querying. Use separate operational tooling where those functions are required.

Keep the jobs separate.

What I would change at scale

At larger process counts, move polling to one internal distributor or sidecar per deployment unit, then fan out a validated snapshot. That reduces synchronized reads and gives the team one place to observe snapshot age. Preserve local defaults in every application process anyway. The distributor can fail too.

I would also persist a revision or content hash beside every cohort decision, test the fresh-to-stale-to-default transitions with a fake clock, and alert on snapshot age before the hard cutoff. Do not silently extend maxStaleMs during an incident. That changes the rollback contract precisely when nobody should be improvising it.

There is a limit to this pattern. Polling is practical when the allowed rollback delay is measured in the same order as the polling interval and request volume is acceptable. It is the wrong mechanism when near-immediate propagation, evaluation analytics, dependency graphs, or a durable administrative audit trail are hard requirements.

The decision rule: keep the control path local, treat cached remote state as temporary evidence, and make stale treatment expire. Then select a provider for the governance and propagation guarantees the experiment actually needs, not for the prettiest boolean API.

Sources

Top comments (0)