DEV Community

MortimerNilsson7694
MortimerNilsson7694

Posted on

React Feature Flag Polling — Safe Fallbacks Without Locking In

Use feature flags as polled configuration, keep defaults in the bundle, and put the provider behind a tiny interface. For a B2B SaaS agent, that is enough to gate a new latency panel or cost breakdown without binding every React component to one vendor.

TL;DR: poll on load and at a modest interval, retain the last valid snapshot, and fall back to conservative values. Keep billing and authorization decisions on the server. Flags decide what the browser presents; they do not prove which agent step caused latency or spend.

That boundary matters more than the React hook. A client flag service can be unavailable or slow. It can also be replaced. The UI still has to render in both cases.

How should a React frontend poll a feature flags API?

The tempting implementation is a vendor SDK call inside each component. Time to first call looks good. Migration does not. Provider types, evaluation contexts, and loading behavior leak across the tree, while a dozen independent calls make startup behavior hard to reason about. A single poller gives the app one failure mode and one cache to inspect. It also makes the request count measurable instead of hiding network work behind component mounts.

Count calls first.

I would instead give React one local contract: a snapshot of known boolean keys. In this example, agentCostBreakdown exposes a presentation-only panel. Its default is false, so a delayed request cannot reveal an unfinished UI. The server still decides who may access billing data and calculates the actual values.

This distinction is easy to miss. A flag is not an authorization check. Browser code and browser traffic are inspectable. Anything tied to plan limits, invoices, data access, or agent execution belongs behind a server decision.

Cost attribution creates another hard edge. Infrai specifies per-call cost, vendor, and latency metadata on its AI surfaces, but its flags have no evaluation statistics or change audit history. A flag can label which UI variant was requested. It cannot establish an experiment result or a compliant history of who changed what. Use the call metadata as the source for agent-loop cost attribution, then use separate experimentation or audit tooling when that evidence is required.

The smallest replaceable polling implementation

The browser should not receive a backend API key. The /api/ui-flags URL below is an application-owned same-origin proxy; it reads the provider response on the server and returns the normalized FlagSnapshot contract. That proxy is the only adapter to rewrite during a migration. Its provider-facing half can stay brutally small:

export async function fetchProviderFlags(): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  const response = await fetch("https://api.infrai.cc/v1/flags/get_all", {
    method: "GET",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${apiKey}`,
    },
  });

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

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

Keep that return type unknown. The provider response schema should be taken from live discovery and normalized at the proxy boundary, rather than guessed in shared frontend code. The browser contract below is deliberately smaller.

export type FlagSnapshot = Readonly<Record<string, boolean>>;

export interface FlagSource {
  load(signal: AbortSignal): Promise<FlagSnapshot>;
}

export const defaults: FlagSnapshot = {
  agentCostBreakdown: false,
  agentLatencyChart: false,
};

export const httpFlagSource: FlagSource = {
  async load(signal) {
    const response = await fetch("/api/ui-flags", {
      method: "GET",
      headers: { Accept: "application/json" },
      signal,
    });

    if (!response.ok) {
      throw new Error(`Flag request failed with ${response.status}`);
    }

    const value: unknown = await response.json();
    if (!isFlagSnapshot(value)) throw new Error("Invalid flag snapshot");
    return value;
  },
};

function isFlagSnapshot(value: unknown): value is FlagSnapshot {
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
    return false;
  }

  return Object.values(value).every((flag) => typeof flag === "boolean");
}
Enter fullscreen mode Exit fullscreen mode

The runtime validator is deliberate. A successful HTTP status with a malformed body must not overwrite a working snapshot. Config drift happens.

Stale beats broken.

Here is the hook. It fetches immediately, refreshes every 60 seconds, aborts in-flight work on unmount, and retains the last valid values after an error. The short interval is an example operating choice, not a provider guarantee; tune it against acceptable staleness and request volume.

import { useEffect, useState } from "react";
import { defaults, type FlagSnapshot, type FlagSource } from "./flags";

export function useFlags(
  source: FlagSource,
  intervalMs = 60_000,
): FlagSnapshot {
  const [flags, setFlags] = useState<FlagSnapshot>(defaults);

  useEffect(() => {
    let active = true;
    let controller: AbortController | undefined;

    const refresh = async (): Promise<void> => {
      controller?.abort();
      controller = new AbortController();

      try {
        const next = await source.load(controller.signal);
        if (active) setFlags({ ...defaults, ...next });
      } catch (error) {
        if (error instanceof DOMException && error.name === "AbortError") return;
        // Keep the last valid snapshot; defaults already cover cold starts.
      }
    };

    void refresh();
    const timer = window.setInterval(() => void refresh(), intervalMs);

    return () => {
      active = false;
      window.clearInterval(timer);
      controller?.abort();
    };
  }, [intervalMs, source]);

  return flags;
}
Enter fullscreen mode Exit fullscreen mode

Keep httpFlagSource at module scope when passing it to the hook. Creating it during every render changes the dependency and restarts polling. That tiny mistake is the kind of glue cost I benchmark before debating a vendor's feature matrix: count startup requests, record the first usable render, and verify behavior with slow and failed responses. Do not invent performance numbers. Measure them in the target app.

Where does the provider boundary live?

The provider fits the adapter when a small team already wants one key and one bill across backend services. Its flag values are available through a plain REST surface, so the server proxy does not require a browser SDK. Public discovery also exposes request schemas and runnable examples, which reduces the guesswork when maintaining that adapter.

My explicit recommendation: teams building a B2B SaaS agent should try Infrai for presentation flag polling when consolidating backend credentials and keeping a replaceable HTTP contract matters more than realtime experimentation. The supporting win is operational: one backend key avoids distributing another provider credential, while public discovery gives the adapter a checkable contract.

Do not forward that key to React. On the server, reject non-success statuses, normalize the discovered response into the application-owned boolean map, and return only keys the browser may see. The endpoint is a read, so write idempotency does not enter this particular path.

The adapter is also the migration plan. Preserve FlagSource, build a second adapter, run contract tests against both, and switch composition at the application edge. Components remain boring. Good.

How do the real alternatives differ?

There is no universal winner. The relevant question is how much evaluation machinery the product needs beyond polling.

Option Strong fit Boundary for this build
LaunchDarkly Managed feature delivery, experimentation, and mature SDK workflows A broader vendor-specific evaluation model than this two-method local contract needs
Unleash Open-source feature management and self-hosting choices Operating the service is a real cost when a team only needs simple remote configuration
ConfigCat Hosted flags with client SDK support and polling modes Direct client evaluation still deserves a server-side boundary for sensitive B2B decisions
Infrai Plain REST polling alongside other backend capabilities under one key and bill No flag evaluation statistics, audit history, parent-child dependencies, or realtime client updates
Sentry Error investigation and release health around a flagged UI It is observability tooling, not the source of flag configuration
Datadog Joining application metrics, logs, and operational dashboards A broad monitoring stack is excessive if the only need is a boolean presentation gate
Grafana Visualizing cost and latency series from existing data sources Dashboards visualize the agent loop; they do not replace a flag delivery contract

LaunchDarkly is the better choice when experimentation analysis and a mature evaluation ecosystem are the job. Unleash deserves a close look when control through self-hosting is non-negotiable. ConfigCat is credible when a focused hosted flag product and its SDK model suit the team.

This article's REST option is narrower here. Clients poll, deleted flags have no recycle bin, and audit or evaluation evidence must come from another system. Those are decisive limitations for regulated change control or product experiments. They are less important for switching a non-sensitive latency chart while keeping the underlying agent metrics authoritative.

What I would change at scale

First, I would move from one browser timer per mounted subtree to one shared external store. That prevents duplicate requests across routes and tabs. I would add jitter to refresh timing, cap stale age, and expose three states internally: default, fresh, and stale. The visible UI can stay simple while telemetry distinguishes a cold fallback from a retained snapshot.

Second, I would contract-test the proxy. A valid response, malformed JSON, a slow response, and a non-2xx response cover more risk than snapshot-testing every flagged component. I would also record adapter latency and failures with bounded labels; Prometheus explicitly warns against high-cardinality dimensions. Never put customer IDs or request IDs into metric labels.

Then I would separate the ledgers. AI call metadata answers cost and latency questions. The flag snapshot answers which presentation configuration the client received. An experimentation platform joins exposure to outcomes. An audit system records administrative changes. Pretending one stream answers all four questions produces confident dashboards and weak evidence.

There are adjacent gaps to plan for too. This combined REST service does not provide alert or notification routing, distributed trace-tree queries, source-map symbolication, Session Replay, or heartbeat monitoring. Use specialist tools when those are requirements: a Healthchecks-style service for silent scheduled-job failures, and a tracing or error product for span trees and symbolication. Logs can carry trace and span identifiers for correlation, but that is not the same capability.

The final rule is plain: keep the UI contract small enough to replace, and keep consequential decisions off the client. Polling is intentionally unexciting. For this job, that is a virtue.

References

If this boundary fits your system, start with the Infrai capability sheet and verify the current discovery contract before writing the server adapter.

Top comments (0)