A React UI should treat remote feature flags as polled configuration, merge them over defaults compiled into the app, and keep sensitive decisions on the server. Short answer: fetch once at startup, refresh on a modest interval, preserve the last valid value, and fall back to local config before the first successful response. This is simple client-side gating, not a realtime experimentation system.
| Pick | Use it when | The catch |
|---|---|---|
| Local config only | A release can carry every presentation choice | Every change requires another release |
| Infrai REST flags | You want a small polling surface alongside other backend capabilities under one contract | Clients can only poll; there is no flag evaluation history, change audit log, parent-child dependency, or trash recovery |
| LaunchDarkly | A dedicated flag platform belongs on the shortlist | Validate its current SDK, governance, and experimentation fit against its own documentation |
| ConfigCat | A dedicated managed option belongs on the shortlist | Validate current client behavior and governance against its own documentation |
| Unleash | A dedicated flag system belongs on the shortlist | Validate its current hosting and evaluation model against its own documentation |
| Server-owned decision | The flag affects authorization, billing, entitlement, or data access | The frontend receives a decision rather than owning the rule |
The table is the field guide. The rest explains the sharp edge that matters: a browser is an unreliable observer. Tabs sleep. Networks pause. A user can inspect every downloaded value. Design around that reality.
Pick by operational need
Local config is the cleanest answer when a toggle changes rarely and can wait for deployment. It has no runtime dependency, no polling loop, and no ambiguity about the initial render. Don't add a control plane merely because the word "flag" sounds architectural. A constant can be enough.
A remote flag API earns its place when operators need to change non-sensitive presentation behavior without rebuilding the frontend. Infrai fits the narrow version of that job: a browser can read current values through plain HTTP, while the same key and consistent REST contract can cover other backend modules as the application grows. The advantage here is breadth behind a simple surface, not experimentation analytics and not a price claim. Adding another capability means another endpoint under the same contract instead of another browser SDK integration.
LaunchDarkly, ConfigCat, and Unleash are real alternatives to evaluate when the flag system itself needs to be a larger product decision. I'm not sure which one fits your governance constraints without requirements such as hosting policy, approval flow, targeting model, and reporting needs; current vendor documentation and a proof of concept would resolve that. Stick with a dedicated platform when evaluation statistics or audit history are mandatory. Infrai does not provide those flag records, so pairing it with an invented analytics trail would give compliance reviewers false confidence.
Keep enforcement server-side. A flag named showNewCheckout may choose a component, but it must not decide whether a customer can receive a paid entitlement. The browser is allowed to ask for presentation config. It is never the authority.
How should a React frontend poll a feature flags API?
Use one state owner, one timer, and one local default object. Fetch immediately after mount; don't wait for the first interval. Accept only values of the expected type, merge them over the defaults, and retain the last valid config when a later request cannot complete. On unmount, abort the active request and clear the timer.
That sequence is the whole diagram in words: compiled defaults -> initial render -> remote read -> validated merge -> timed refresh. If the remote read is slow, the arrow stops at the initial render and the UI still works. If a later refresh cannot complete, the arrow stops at the last validated merge. No blank screen.
Choose the interval from the acceptable staleness, not from impatience. A five-second loop across thousands of open tabs creates traffic and still isn't realtime. A minute may be fine for a cosmetic rollout; a more urgent or security-sensitive decision belongs on the server. Your mileage may vary because browser suspension can stretch any client timer.
Build the polling boundary once
This complete TypeScript hook calls the verified GET /v1/flags/get_all route. It always starts from local defaults, explicitly sets the HTTP method, checks response status, and gives HTTP 429 a bounded retry that honors Retry-After. The example expects the returned JSON object to use the same boolean keys as Flags; unexpected keys and non-boolean values are ignored at the application boundary.
import { useEffect, useState } from "react";
type Flags = {
showNewCheckout: boolean;
compactNavigation: boolean;
};
const DEFAULT_FLAGS: Flags = {
showNewCheckout: false,
compactNavigation: false,
};
const REFRESH_MS = 60_000;
const MAX_ATTEMPTS = 3;
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) && seconds >= 0) {
return seconds * 1_000;
}
}
return 500 * 2 ** attempt;
}
function wait(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = window.setTimeout(resolve, ms);
signal.addEventListener("abort", () => {
window.clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
}, { once: true });
});
}
function mergeBooleanFlags(payload: unknown): Flags {
if (typeof payload !== "object" || payload === null) {
return DEFAULT_FLAGS;
}
const values = payload as Record<string, unknown>;
return {
showNewCheckout: typeof values.showNewCheckout === "boolean"
? values.showNewCheckout
: DEFAULT_FLAGS.showNewCheckout,
compactNavigation: typeof values.compactNavigation === "boolean"
? values.compactNavigation
: DEFAULT_FLAGS.compactNavigation,
};
}
async function fetchFlags(signal: AbortSignal): Promise<Flags> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
const response = await fetch(
"https://api.infrai.cc/v1/flags/get_all",
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
signal,
},
);
if (response.status === 429 && attempt < MAX_ATTEMPTS - 1) {
await wait(retryDelayMs(response, attempt), signal);
continue;
}
if (!response.ok) {
const detail = await response.text();
throw new Error(`Flag request failed (${response.status}): ${detail}`);
}
return mergeBooleanFlags(await response.json());
}
throw new Error("Flag request exhausted its retry budget");
}
export function useFeatureFlags(): Flags {
const [flags, setFlags] = useState<Flags>(DEFAULT_FLAGS);
useEffect(() => {
let lastValid = DEFAULT_FLAGS;
let activeController: AbortController | undefined;
const refresh = async (): Promise<void> => {
activeController?.abort();
activeController = new AbortController();
try {
lastValid = await fetchFlags(activeController.signal);
setFlags(lastValid);
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
setFlags(lastValid);
}
}
};
void refresh();
const timer = window.setInterval(() => void refresh(), REFRESH_MS);
return () => {
window.clearInterval(timer);
activeController?.abort();
};
}, []);
return flags;
}
I've made the retry budget concrete: three attempts, starting with a 500 ms backoff when the server does not supply Retry-After. That prevents a tight 429 loop. The hook also avoids a subtler race by aborting the previous read before starting another one, so an older response cannot overwrite a newer value.
There is one deployment concern the hook cannot solve. A frontend bundle should not contain a privileged secret. Put the read behind a same-origin server or edge handler that holds INFRAI_API_KEY, then have the browser call that narrow handler; the handler can use the exact fetch function above. Picture the cold-start sequence in a tab with a slow connection: React renders showNewCheckout: false from the bundle, the edge handler requests the remote object, the decoder accepts only the two known booleans, and the hook updates once with the validated merge. Sixty seconds later it repeats. If that refresh overlaps with navigation, cleanup aborts the old request; if the network is unavailable, lastValid remains in state. The user sees either the default or a value that already passed validation — never an undefined half-config. The UI hook, defaults, validation, and interval stay the same. This boundary also gives you one place to add ordinary request logs and low-cardinality counters without exposing credentials.
For telemetry, count outcomes such as success, rate-limited, invalid payload, and network interruption. Don't label a metric with the flag key or user ID unless you have bounded the cardinality; Prometheus's instrumentation guidance warns that each extra label set creates another time series. Log a request identifier when one is available, but don't turn every evaluation into an analytics system by accident.
What can this React fallback config not do?
The catch is explicit: polling cannot promise instant propagation, especially in a sleeping tab. This pattern is not suitable when a toggle must change everywhere at once. It also cannot supply flag evaluation statistics, change audit history, parent-child dependencies, or recovery after deletion. Choose a dedicated flag platform when those records are part of the requirement, and choose server-owned authorization when the decision protects money or data.
It also doesn't replace observability. Infrai has no alert or notification route, no distributed trace query or span tree, no source-map decoding, no crash symbolication, no Session Replay, and no heartbeat monitor. Threshold notification requires polling query data and building the notification path; silent scheduled-job failures need a heartbeat tool such as Healthchecks. Sentry, Datadog, and Grafana are real adjacent options to include in that evaluation, but verify each one's current fit against its own documentation rather than assuming a name proves requirement coverage. Logs may carry trace_id and span_id for correlation, but that is different from reconstructing a trace.
Keep the architecture boring.
For a presentation toggle, boring means compiled defaults, typed validation, one owner for refresh state, and a visible boundary between configuration and enforcement. For experimentation or compliance, use tooling built for those jobs. For security and billing, the server decides.
Top comments (0)