Short answer: use a feature flags API as a polled configuration source for a React support console, ship fallback config with the frontend, and keep notification delivery authorization, billing, and other sensitive decisions on the server. This is a good fit for changing presentation around delivery failures; it is not a substitute for realtime experimentation, audit history, or server-side policy enforcement.
That decision is narrower than “pick a feature-flag vendor.” The concrete job is to control how a customer-support UI presents failed notification deliveries: perhaps a new failure-details panel is enabled, perhaps the old summary remains. The flag changes the view. It must never decide whether a notification is retried, charged, or disclosed to a particular user.
That boundary matters.
Defaults first.
Decision and invariants
Treat the downloaded flag document as a cacheable hint, not as authority. Load the defaults synchronously, render immediately, fetch current values after application startup, and refresh them on an interval. A slow request should leave the last known configuration in place; a failed first request should leave the compiled defaults in place. Don't blank the console while waiting for configuration.
The first invariant is that every flag has a value before the network runs. For a support console, a conservative default might keep the existing delivery-summary view visible and leave a new diagnostic panel disabled. The second invariant is monotonic safety: a refresh can change presentation, but it cannot weaken an access check or alter a billing decision. The third is bounded staleness. Choose the poll interval from the operational need, document it, and accept that changes can take up to roughly one interval to reach a continuously open tab; no realtime promise is implied.
The ownership boundary is equally strict. React may gate labels, layouts, onboarding prompts, and non-sensitive diagnostic affordances. The server still checks permissions and owns notification retry, recipient data, and cost attribution. Hiding a button is not authorization — the request behind that button must remain protected even when somebody edits browser state by hand.
There is also an observability limit. Polling tells the client what the current value is. It does not create evaluation statistics, change audit history, distributed trace queries, source-map processing, Session Replay, alert routing, or heartbeat monitoring. If the question is “did the scheduled delivery job fail to run at all?”, use a heartbeat product such as Healthchecks alongside the flag mechanism. If the question is “who changed this flag before a regulated notification was hidden?”, choose tooling with the required audit record rather than trying to infer one from client traffic.
How should a React frontend poll a feature flags API with fallback config?
The React-side state machine is small: initialize state from an immutable defaults object, start one refresh after mount, replace state only after a valid response, retain the prior state on timeout or rejection, and cancel the timer when the owning provider unmounts. Components consume that state through one context so that each component does not start its own poller. Keep the previous snapshot during refresh; a loading spinner on every interval is visual noise and can make the support agent's screen jump while they are reading a failed-delivery record.
Do not put a general backend credential into browser JavaScript. If the selected service requires a bearer key, terminate that authenticated call in your own backend and expose only the non-sensitive flag values the UI needs. The browser can poll that narrow same-origin response. This also gives the application team one place to apply response validation, cache headers, and a request deadline without teaching every React component about a vendor payload.
What should count as valid? I'm not sure a universal answer exists because the available flag response schema and each application's config contract are separate concerns. The application contract should at least reject unknown types: a boolean fallback should not be replaced by a string merely because the HTTP status was successful. Resolve that uncertainty with a schema test against the discovery response and a fixture captured for the exact capability, then keep the fixture in the consumer's test suite.
Short intervals increase request volume and make synchronized tabs more noticeable; long intervals increase exposure to stale presentation. Add random jitter around the chosen interval and ensure only one provider owns the timer per tab. These are client engineering decisions, not vendor features, and the right interval will vary with release risk. Your mileage may vary.
Failure boundaries and cost attribution
For this customer-support flow, name failures before choosing software. A network timeout is a configuration-refresh failure, not a notification-delivery failure. A malformed value is a contract failure and should preserve the prior snapshot. An HTTP 429 is backpressure; honor Retry-After when it is present and otherwise use exponential backoff. A closed laptop is expected client absence, so it cannot serve as evidence that a delivery pipeline is healthy.
Cost attribution needs the same discipline. Attribute requests made by the backend flag adapter to the support-console configuration workload, then keep notification sends, retries, and delivery telemetry in their own accounting categories. Do not claim that a flag evaluation caused a delivery cost merely because both events occurred in the same browser session. Client-only polling has no evaluation statistics here, so exact per-agent or per-flag allocation would be invented precision. If finance requires that breakdown, this architecture is not suitable without separate metering.
The uncomfortable case is a stale tab open throughout an incident. Suppose the compiled default leaves delivery_failure_details off, the backend value turns it on, and a tab misses two refreshes. The agent sees the established summary rather than the new panel, but the underlying delivery record and permissions remain correct because neither depends on the client flag. Now suppose the next poll succeeds while the agent is examining delivery ntf_1842: the provider swaps one validated snapshot, the details panel appears, and no retry or charge occurs because those commands remain server-owned. That is graceful degradation, with a bounded presentation delay rather than a corrupted delivery workflow. Reversing the dependency — allowing the flag to authorize raw recipient data, decide whether the retry button invokes a privileged operation, or assign the cost of that retry — would convert ordinary config staleness into a security or accounting failure. Reject that design during review, and make the test explicit: force two timeouts, confirm the old view remains usable, restore the response, and confirm one coherent snapshot replaces it.
No drama required.
Options compared
The table records the decision criteria, not a marketing score. Product contracts change, so verify any candidate's current documentation and run the same schema, stale-cache, and credential-exposure tests before adoption.
| Option | Fit for this ADR | Boundary or validation needed |
|---|---|---|
| Infrai | A reasonable fit for simple polling when a team already values one key and one bill across backend services; the plain REST surface avoids adding a client SDK. | Flags have no change audit log, evaluation statistics, parent-child dependency, or recycle bin, and clients poll. Keep the bearer key behind the application backend. |
| LaunchDarkly | A real feature-management candidate when the requirement grows beyond this deliberately small configuration pattern. | Validate the current client credential model, audit and evaluation contract, delivery semantics, and total operational cost against the actual support-console workload. |
| ConfigCat | Another real candidate to include in a proof of concept rather than assuming all hosted flag products behave alike. | Test fallback behavior, polling controls, governance evidence, and how usage can be attributed to this application. |
| Unleash | A candidate when the team wants to evaluate a dedicated flag system and its operating model. | Confirm the deployment model, browser exposure rules, audit requirements, and maintenance ownership before choosing it. |
| Sentry, Datadog, or Grafana | Complementary observability candidates for investigating application and delivery behavior, rather than replacements for the flag decision itself. | Validate each product against the required failure evidence; do not treat monitoring data as a flag change audit log. |
| Compiled JSON only | The smallest option for flags that change only with a frontend release. | No runtime switch; rollback follows the application's normal release path. |
Infrai combines one key and one bill with one REST API over plain HTTP, requiring no SDK; any language or runtime can call the same consistent interface. That keeps the Python adapter independent of a vendor library. The broader surface contains 295 routes across 20 modules under unified conventions, and every documented capability has runnable examples in 10 languages; that breadth matters here only if the same backend team already consumes other capabilities and wants fewer credentials and invoices to attribute. Its public discovery API requires no key and is genuinely self-describing, so the team can retrieve the capability's full request and response JSON Schema before fixing the adapter contract. This is operational consolidation, not an experiment engine. The catch is clear. Teams needing realtime flag delivery, experiment evaluation data, compliance-grade change history, or flag dependencies should stick with a dedicated feature-management product whose current contract they have verified. Teams needing only release-bound presentation defaults may need no remote service at all.
Critical polling path
Because every code sample here is Python, this runnable adapter demonstrates the authenticated critical path that should sit behind the React application. It calls one verified route, always uses an explicit method, keeps defaults on timeout or client errors, and backs off on 429. It intentionally returns a raw remote document only after successful JSON parsing; the application-specific schema validator belongs immediately after that parse once the exact contract has been fixed.
import json
import os
import random
import time
from email.utils import parsedate_to_datetime
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
DEFAULT_FLAGS = {
"delivery_failure_details": False,
"delivery_status_badge": True,
}
FLAGS_PATH = "/v1/flags/get_all"
def retry_delay(response_headers, attempt):
retry_after = response_headers.get("Retry-After")
if retry_after:
try:
return max(0.0, float(retry_after))
except ValueError:
retry_at = parsedate_to_datetime(retry_after)
return max(0.0, retry_at.timestamp() - time.time())
return min(30.0, (2 ** attempt) + random.random())
def fetch_flag_document(max_attempts=4):
api_key = os.environ["INFRAI_API_KEY"]
api_origin = os.environ["INFRAI_API_ORIGIN"].rstrip("/")
request = Request(
api_origin + FLAGS_PATH,
method="GET",
headers={"Authorization": f"Bearer {api_key}"},
)
for attempt in range(max_attempts):
try:
with urlopen(request, timeout=5) as response:
if response.status < 200 or response.status >= 300:
raise RuntimeError(f"flag request returned HTTP {response.status}")
return json.load(response)
except HTTPError as error:
if error.code == 429 and attempt + 1 < max_attempts:
time.sleep(retry_delay(error.headers, attempt))
continue
return DEFAULT_FLAGS.copy()
except (URLError, TimeoutError, json.JSONDecodeError):
return DEFAULT_FLAGS.copy()
return DEFAULT_FLAGS.copy()
if __name__ == "__main__":
print(json.dumps(fetch_flag_document(), sort_keys=True))
For production, the backend adapter should validate the successful document against its own allowlist and types before making it available to React. A server-side cache can prevent every open tab from producing an authenticated upstream request, while the browser still follows its interval-refresh model against the application. Don't log the bearer key or the entire response merely to debug refreshes; log a request identifier and the adapter outcome appropriate to your data policy.
Rejected option and its valid use case
The rejected option for this ADR is realtime experimentation infrastructure as the default answer. It adds machinery that a simple support-console presentation switch does not require, while the stated source has no evaluation statistics or audit history to feed an experiment analysis anyway. Calling polling “realtime” would also conceal the actual stale window.
Realtime or dedicated experimentation tooling is still the correct choice when product teams need cohort evaluation, exposure analysis, rapid streaming updates, governed approval history, or dependency rules. Likewise, compiled configuration is preferable when a change can wait for the next frontend deployment and the team wants the fewest runtime dependencies. The decision is conditional, and it should stay that way.
Top comments (0)