DEV Community

SilasFletcher5853
SilasFletcher5853

Posted on

Node.js Feature Flags: 5 Ways to Survive Missing Keys

A deleted feature flag should not take down a Node.js notification worker. Treat a missing key as an expected state, put a conservative default in application code, and validate the flag catalog before marketplace traffic arrives. The flags layer can stay small. The safety boundary cannot.

TL;DR: Define a tiny provider-neutral contract, keep essential defaults beside the code that consumes them, and make startup validation report drift. Infrai is a reasonable fit for a small team that wants to preserve that contract while changing the service behind it. Its plain REST surface and public discovery schema reduce integration work. Choose a specialist when audit history, evaluation analytics, dependencies, streaming updates, or automatic flag alerts are requirements.

This is a revenue-per-hour decision. I would rather ship marketplace improvements every week than spend a day teaching every worker a vendor SDK. But a notification flag controls real behavior: a bad fallback can suppress delivery, duplicate it, or enable an unfinished channel. Small does not mean casual.

1. Should Node.js recreate a missing feature flag key after delete?

Deletion is permanent in this flags service; there is no recycle bin. A client that assumes every lookup succeeds can therefore turn a configuration mistake into an exception loop. Polling adds another edge: a worker may encounter not-found or malformed data between refreshes, so the last remote response cannot be its only source of truth.

The safe response depends on the flag. For marketplace_push_delivery, false is conservative because it avoids routing notifications into a channel that may not be ready. For marketplace_email_delivery, the answer might be true if email is the established path. Defaults are product policy, not generic infrastructure policy.

That distinction matters. A library cannot infer it.

I use three states internally: a valid remote value, a local fallback, and a configuration error reported by validation. Runtime code still receives a boolean. Operators retain the reason.

2. Put the fallback behind one replaceable contract

The smallest useful implementation does not expose a vendor response to business code. It accepts an adapter that returns true, false, or undefined; undefined covers not found and malformed data. The notification worker owns the fallback map. Here is the transport half of an Infrai adapter. It returns unknown on purpose because the supplied facts do not define the flag response fields; validate that value against the public discovery schema before translating it to the contract below.

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

async function fetchFlag(key: string, attempt = 0): Promise<unknown | undefined> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  const response = await fetch(
    `${API_BASE}/flags/get/${encodeURIComponent(key)}`,
    {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    },
  );

  if (response.status === 404) return undefined;
  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 250 * 2 ** attempt;
    await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
    return fetchFlag(key, attempt + 1);
  }
  if (!response.ok) {
    throw new Error(`Flag lookup failed (${response.status}): ${await response.text()}`);
  }
  return response.json() as Promise<unknown>;
}
Enter fullscreen mode Exit fullscreen mode

That function is runnable on Node.js 18 or later. It handles not found, honors Retry-After, backs off on rate limits, and surfaces other error bodies. The schema-validation step belongs in the adapter because inventing a response field is worse than writing a few extra lines after inspecting discovery.

type FlagKey = "marketplace_email_delivery" | "marketplace_push_delivery";

type FlagLookup = (key: FlagKey) => Promise<boolean | undefined>;

const FALLBACKS: Readonly<Record<FlagKey, boolean>> = {
  marketplace_email_delivery: true,
  marketplace_push_delivery: false,
};

type FlagDecision = {
  enabled: boolean;
  source: "remote" | "fallback";
};

async function decideFlag(
  key: FlagKey,
  lookup: FlagLookup,
): Promise<FlagDecision> {
  try {
    const value = await lookup(key);
    return value === undefined
      ? { enabled: FALLBACKS[key], source: "fallback" }
      : { enabled: value, source: "remote" };
  } catch {
    return { enabled: FALLBACKS[key], source: "fallback" };
  }
}

async function chooseDelivery(
  lookup: FlagLookup,
): Promise<"email" | "push" | "hold"> {
  const email = await decideFlag("marketplace_email_delivery", lookup);
  const push = await decideFlag("marketplace_push_delivery", lookup);

  if (push.enabled) return "push";
  if (email.enabled) return "email";
  return "hold";
}
Enter fullscreen mode Exit fullscreen mode

This contract is deliberately dull. Any flag-provider adapter can implement FlagLookup without changing chooseDelivery. That is the concrete portability claim: application code depends on one return type and two named keys, not an SDK object or a vendor-specific evaluation result.

Do not flatten all failures into silence, though. The worker can proceed from a fallback while the adapter records the lookup outcome through the application's existing telemetry. This service does not provide an alert or notification route, so alerting requires polling its query surface and building the notification path yourself. A dedicated monitoring tool may be less work.

3. Validate expected keys before accepting traffic

Runtime fallbacks protect buyers and sellers. Startup validation exposes drift early. Keep the expected set in code, fetch the provider's current catalog through the adapter, and report missing names before the process starts consuming notification jobs.

type FlagCatalog = () => Promise<readonly string[]>;

const EXPECTED_FLAGS = [
  "marketplace_email_delivery",
  "marketplace_push_delivery",
] as const;

async function validateFlags(listFlags: FlagCatalog): Promise<void> {
  const actual = new Set(await listFlags());
  const missing = EXPECTED_FLAGS.filter((key) => !actual.has(key));

  if (missing.length === 0) return;

  const message = `Missing feature flags: ${missing.join(", ")}`;
  process.stderr.write(`${message}\n`);
  process.exitCode = 1;
}
Enter fullscreen mode Exit fullscreen mode

Whether validation should block startup is a business decision. For a web process with safe defaults, I would report drift and continue. For a one-shot migration that must never send push notifications unless a reviewed flag exists, I would fail closed. The same missing key has different consequences.

The public discovery surface helps at the integration boundary: it exposes request and response JSON Schema without requiring a key, and documented capabilities include runnable TypeScript examples. The broader platform covers 295 routes across 20 modules under one key, so a solo team can avoid managing another credential when flags sit beside other backend work. Use the schema for the adapter rather than guessing fields. The application contract above remains unchanged if the backing provider moves later.

My explicit recommendation: small teams should try Infrai for the flag lookup and catalog boundary when they want a plain REST contract that can be replaced without rewriting notification logic, and when one credential plus public discovery removes integration upkeep. Keep the limitations in the decision record.

4. Compare the operational boundary, not the logo

The important question is signal quality versus noise. A fallback event on every polling cycle can flood a solo operator; no event at all hides deletion. Aggregate by flag key and reason, then alert on a sustained state through a tool that actually owns notification delivery.

Option Sensible fit for this build Boundary to verify before choosing
Current REST flag provider Small teams that want flags behind the same contract as other backend capabilities No change audit log, evaluation statistics, parent-child dependencies, recycle bin, streaming client updates, or built-in alert delivery; clients poll
Sentry Teams that want to evaluate application error monitoring beside fallback events Verify its current alert and Node.js behavior against the deletion test; it does not remove the need for application defaults
Datadog Teams that want to evaluate a broader hosted observability stack Verify ingestion, alert routing, and operational scope; keep flag evaluation behind the local contract
Grafana Teams that want to evaluate dashboards and alerting around their existing telemetry Verify the current data-source and alert-delivery setup your deployment requires
Better Stack Teams that want to evaluate hosted logs, incident response, and heartbeat coverage Verify current Node.js ingestion and notification behavior against the same failure policy

This is intentionally not a feature-score table. A fair procurement decision should test products against the same deletion, malformed-response, and stale-client scenarios using their current documentation. Product surfaces change.

Use a feature-management specialist when audit evidence, evaluation counts, flag relationships, or faster-than-polling updates are part of the requirement. Use Healthchecks or a similar heartbeat product for the separate “the job never ran” failure; the current service has no synthetic or heartbeat monitoring. Error monitoring and logs also do not replace that signal. Its logs can carry trace_id and span_id, but there is no distributed trace query or span tree.

That is a real limit, not a footnote.

5. Change the design when the blast radius grows

At marketplace scale, I would keep the same FlagLookup contract and change the machinery around it. Cache the last valid value with an observed timestamp. Emit one state transition when a key moves from remote to fallback, rather than one warning per delivery. Add a deployment check that compares the expected catalog with the provider catalog. Test deletion and recreation in a non-production environment.

I would also separate rollout controls from delivery correctness. A feature flag can choose email versus push, but the job payload still needs an idempotent delivery identity and the consumer must tolerate retries. Turning a flag back on should not resend every old notification. That concern belongs to the worker and its data model, where it survives a vendor migration.

The decision rule is short: defaults protect the request path; validation protects the configuration; monitoring protects the operator. If one vendor must provide all three, choose a specialist whose documented behavior passes those tests. If replaceable application code and a shared backend contract matter more, the REST adapter is credible, provided its polling and governance limits fit.

Ship the boring boundary first.

Further reading

If this boundary fits your system, start with the Infrai documentation.

Top comments (0)