A React or Next.js feature flags polling client for a customer-support notification service has an awkward constraint: it should reveal delivery-status UI changes quickly, but a stale browser must never decide whether a paid message is sent. That constraint changes the design.
TL;DR: fetch the current flags when the app loads, poll at a modest interval, and use the result only for low-stakes presentation such as hidden navigation, beta badges, and optional panels. Keep billing, authorization, and emergency delivery stops on the server. Polling works here. It isn't an instant kill switch.
Should a frontend feature flag client use polling?
The useful question isn't whether polling is sophisticated. It is whether the maximum stale interval is acceptable for the decision behind the flag.
For an agent-facing badge that labels a new delivery-failure view, 30 seconds may be fine. For a switch that prevents another paid notification from leaving the system, even one stale browser decision is the wrong architecture. The server must enforce that rule at send time. The browser can mirror it, but it cannot own it.
This split also keeps cost attribution credible. A UI flag may reveal an experimental failure dashboard. The event that incurs cost should still be tagged and recorded by the server that performs the send, rather than inferred from whichever flag snapshot happened to be in a tab. Those are different records with different jobs.
There is no realtime push in this setup, so the client loads all current flags once and refreshes them on an interval. There is also no flag evaluation history or change audit trail. If product or compliance needs to answer who changed a rollout, when it changed, or which evaluation produced a send, record those decisions in a separate system.
The smallest implementation I would ship
Do not put a service key in browser JavaScript. The small implementation has two pieces: a server-only loader and a client hook. The loader calls one upstream route, checks every status, and backs off on 429, honoring Retry-After when the server provides it.
The response schema should be validated against the discovery schema during integration. I leave the payload as unknown below because guessing undocumented fields would create a prettier example and a worse client.
const API_ORIGIN = process.env.INFRAI_BASE_URL;
if (!API_ORIGIN) throw new Error("INFRAI_BASE_URL is required");
const FLAGS_URL = new URL("/v1/flags/get_all", API_ORIGIN);
function retryDelay(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) {
return Number(retryAfter) * 1_000;
}
return Math.min(1_000 * 2 ** attempt, 30_000);
}
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
export async function loadFlags(): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(FLAGS_URL, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
cache: "no-store",
});
if (response.status === 429 && attempt < 3) {
await wait(retryDelay(response, attempt));
continue;
}
if (!response.ok) {
throw new Error(`Flag fetch failed (${response.status}): ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Flag fetch exhausted retries");
}
Expose that loader through an authenticated, same-origin application handler such as /api/flags. That handler should validate the upstream payload and return only the non-sensitive keys the UI is allowed to know. The hook below then owns bootstrap, refresh, cleanup, and the unglamorous failure case.
import { useEffect, useState } from "react";
type UiFlags = Readonly<Record<string, boolean>>;
export function useUiFlags(intervalMs = 30_000) {
const [flags, setFlags] = useState<UiFlags>({});
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
async function refresh() {
try {
const response = await fetch("/api/flags", {
method: "GET",
signal: controller.signal,
cache: "no-store",
});
if (!response.ok) {
throw new Error(`Local flag fetch failed (${response.status})`);
}
setFlags((await response.json()) as UiFlags);
setError(null);
} catch (value) {
if (!controller.signal.aborted) {
setError(value instanceof Error ? value : new Error("Flag fetch failed"));
}
}
}
void refresh();
const timer = window.setInterval(() => void refresh(), intervalMs);
return () => {
controller.abort();
window.clearInterval(timer);
};
}, [intervalMs]);
return { flags, error };
}
The deliberate behavior is easy to miss: a refresh error preserves the last known flags. For cosmetic UI, that avoids a panel blinking away during a transient network failure. A fresh page starts from {}, so optional features default off until bootstrap completes. Benchmark time-to-first-flag, request volume per active tab, and staleness under background-tab timer throttling before changing the 30-second interval.
Short code. Real boundaries.
Where the vendor choice changes the design
The products aren't interchangeable because their client delivery models and governance surfaces differ. Compare the mechanism you need, not the number of dashboard screenshots.
| Option | Practical fit | Boundary to account for |
|---|---|---|
| LaunchDarkly | Teams that want mature client SDK behavior and documented streaming or polling controls | More platform surface and SDK integration than this tiny read-only client needs |
| ConfigCat | Frontends that benefit from documented auto-poll, lazy-load, or manual-poll modes | Still requires a deliberate choice about client-visible flags and stale values |
| Unleash | Teams that want an open-source feature-management stack and a frontend API model | Operating or integrating that stack is a larger decision than adding one endpoint |
| A broad backend API | Small teams that want flags beside other backend capabilities under one contract | Client delivery is polling; flag audit history, evaluation statistics, and parent-child dependencies are absent |
Infrai's concrete advantage is breadth behind one credential: 295 routes across 20 modules use one key, so a team that already needs several backend capabilities can add flags without collecting another authentication scheme.
Infrai's second advantage is a single REST API that can be called directly over HTTP from any language or runtime; no SDK is required. For this notification service, the server loader stays a small fetch call instead of bringing another client library into the build. The public discovery surface is self-describing and requires no key, exposing request and response schemas, billing details, and runnable examples. Every documented capability has runnable examples in 10 languages. That cuts the mechanical work between inspecting the flag contract and making the first call.
The trade-off is sharp. Teams that require streaming client updates, flag dependencies, evaluation analytics, or a built-in audit trail should use a dedicated flag platform.
LaunchDarkly is the strongest fit of these four when controlled rollout telemetry and an established client SDK ecosystem dominate the decision. ConfigCat is appealing when explicit polling modes are the main requirement. Unleash makes sense when deployment control and open-source operation outweigh setup work. A free tier may help a prototype, but it doesn't fix the wrong delivery model.
What I would change at scale
First, collapse browser traffic. Ten thousand active tabs polling every 30 seconds can create roughly 333 requests per second before cache effects, even though the underlying flag set may barely move. Put a short-lived shared cache behind the application endpoint, add random jitter to client intervals, and revalidate once per cache window.
Measure it. Don't tune by vibe.
Second, separate three concepts that often get mashed into one boolean: UI exposure, server authorization, and cost-bearing execution. The first may tolerate a cached flag. The second must use trusted server state. The third needs an immutable event carrying the notification type, provider or route choice, tenant, flag snapshot identifier, and resulting cost metadata available to the sending service. This article doesn't prescribe that event schema because the flag API doesn't define one.
At higher stakes, the polling design stops being the right tool. Emergency shutdowns need a server-enforced control path. Compliance-sensitive rollouts need an audit system. Silent scheduled-job failures need a heartbeat monitor such as Healthchecks; a flag poller cannot tell you that a task never ran.
The same restraint applies to adjacent observability work. Log records may carry trace_id and span_id, but that doesn't create a distributed trace query or span tree. Error capture doesn't imply source-map decoding, crash symbolication, or session replay. Sentry is the more direct candidate for error grouping and application exception workflows. Datadog fits a team seeking a wider hosted observability suite, while Grafana fits teams building around dashboards and an observability stack. Better Stack is another dedicated option for logs and uptime. These products don't replace feature flags; they cover limitations that a polling flag client cannot solve.
Decision rule
Use this polling client when the flag changes presentation, a bounded stale window is acceptable, and the server remains authoritative for sensitive actions. Choose a dedicated platform when streaming updates, evaluation statistics, dependencies, or change history are part of the requirement. For delivery failures, keep the dashboard toggle in the browser and the decision to send, suppress, or bill on the server.
That line is boring. It is also testable.
Sources
- LaunchDarkly, “Streaming”: https://launchdarkly.com/docs/sdk/features/streaming
- ConfigCat, “Advanced usage”: https://configcat.com/docs/sdk-reference/js/overview/#polling-modes
- Unleash, “Frontend API”: https://docs.getunleash.io/reference/front-end-api
- OpenFeature, “Web SDK”: https://openfeature.dev/docs/reference/technologies/client/web/
- Healthchecks documentation: https://healthchecks.io/docs/
- Sentry, “Event grouping”: https://docs.sentry.io/concepts/data-management/event-grouping/
- MDN, “Using the Fetch API”: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
Top comments (0)