DEV Community

Falgrim78
Falgrim78

Posted on

React Feature Flags for Fintech: Polling API Fallbacks and Cost-Aware Config

Short answer: for a React frontend that polls feature flags, keep a complete local fallback, validate every remote snapshot, and send structured refresh events into the same cost-attribution trail as the nightly fintech data pipeline. A browser flag is a display decision, not a permission check.

Approach Pick this when Cost and failure trade-off
Bundled defaults A change can wait for the next release No polling spend or stale remote state, but no independent switch
App-owned polling adapter A small set of reversible UI flags needs runtime updates More requests and monitoring work, with clear ownership of fallback behavior
Dedicated flag service Experiments, approvals, dependencies, or evaluation history are requirements More governance and integration surface; compare the operating model carefully
Server-side evaluation A flag controls access to money, data, or an entitlement Stronger enforcement, but each request may add latency and backend work

The example below uses a nightly transaction-enrichment pipeline because that is where observability gets practical. The frontend may show a new reconciliation view; the pipeline writes structured records about how many rows it processed and what the run cost. If those two systems use different names, timestamps, and trace identifiers, a harmless-looking poll can become another unjoinable data source.

What should a React frontend feature flags polling API example record for cost attribution?

Start with a small contract. A flag refresh should produce an event with a stable operation name, a bounded outcome, a duration, and the pipeline or request context that caused it. It should not put the flag payload, a customer identifier, or arbitrary exception text into metric labels.

Think of the path as a diagram in words: bundled fallback -> React render -> application adapter -> flags API -> validated snapshot -> refresh event. The nightly pipeline has a parallel path: scheduled run -> structured log record -> searchable store -> cost report. Give both paths a common trace_id only when they genuinely belong to the same operation; do not invent correlation just to make a dashboard look connected.

For the fintech case, a useful log record might have these fields:

type PipelineLog = {
  timestamp: string;
  service: "nightly-reconciliation" | "flag-adapter";
  operation: "run" | "flag_refresh";
  outcome: "started" | "accepted" | "stale" | "rejected" | "failed";
  runId?: string;
  traceId?: string;
  rows?: number;
  bytesRead?: number;
  durationMs?: number;
  costUnit?: "request" | "byte" | "compute_second";
};
Enter fullscreen mode Exit fullscreen mode

The important part is not the type declaration. It is the boundary around it. The adapter owns normalization, and the pipeline owns its run accounting. A React component should receive flags and status; it should not know how the provider names a field or how a nightly job calculates its usage.

A refresh that returns HTTP 200 but contains an incomplete object is not an accepted snapshot. Log that distinction. Otherwise the cost report says the request succeeded while the UI is still using fallback state.

Name the state.

Choose the polling boundary before choosing an API

Put the authenticated provider call on your server. The browser calls an application endpoint that returns a narrow, public configuration object. This keeps credentials out of shipped JavaScript and lets the adapter attach the same request metadata used by the nightly pipeline.

The browser still needs a useful first render. Bundle defaults for every flag, mark the state as loading, and retain the last accepted snapshot when a later refresh is slow. A failed refresh should not silently replace a known-good value with a different default; that creates a UI change caused by network timing.

Polling is a policy, not a magic timer. A short interval improves propagation but increases request volume. A long interval reduces traffic but extends staleness. I’m not sure which interval is right without the flag’s urgency, active-client count, and endpoint limits. Write those assumptions next to the configuration and revisit them with measured refresh outcomes.

Keep the flag set small. If every transaction type, merchant, or account becomes a flag, you are building a rules engine in the browser. That is expensive to reason about and risky to expose. Use server-side evaluation for decisions that protect a balance, an approval, or customer data.

Implement a validated fallback and an observable refresh

This adapter uses an application-owned endpoint, so it does not pretend to know a provider response shape. The route is a local contract owned by your application. The code also uses a fixed outcome vocabulary, which keeps metrics useful instead of creating a new time series for every error string.

import { useCallback, useEffect, useState } from "react";

type Flags = {
  reconciliationView: boolean;
  exportV2: boolean;
};

type RefreshState = {
  flags: Flags;
  status: "loading" | "fresh" | "stale";
  refreshedAt: string | null;
};

const fallback: Flags = {
  reconciliationView: false,
  exportV2: false,
};

function isFlags(value: unknown): value is Flags {
  if (typeof value !== "object" || value === null) return false;
  const candidate = value as Record<string, unknown>;
  return (
    typeof candidate.reconciliationView === "boolean" &&
    typeof candidate.exportV2 === "boolean"
  );
}

async function loadFlags(signal: AbortSignal): Promise<unknown> {
  const response = await fetch("/internal/feature-flags", {
    method: "GET",
    headers: { Accept: "application/json" },
    signal,
  });
  if (!response.ok) throw new Error("flag refresh was not accepted");
  return response.json();
}

export function usePolledFlags(intervalMs = 30_000): RefreshState {
  const [state, setState] = useState<RefreshState>({
    flags: fallback,
    status: "loading",
    refreshedAt: null,
  });

  const refresh = useCallback(async () => {
    const controller = new AbortController();
    const started = performance.now();
    try {
      const payload = await loadFlags(controller.signal);
      if (!isFlags(payload)) throw new Error("invalid flag snapshot");
      const refreshedAt = new Date().toISOString();
      setState({ flags: payload, status: "fresh", refreshedAt });
      // Emit one bounded event here: outcome=accepted, plus duration.
      console.info("flag_refresh", {
        outcome: "accepted",
        durationMs: Math.round(performance.now() - started),
      });
    } catch {
      setState((current) => ({ ...current, status: "stale" }));
      console.info("flag_refresh", {
        outcome: "stale",
        durationMs: Math.round(performance.now() - started),
      });
    }
  }, []);

  useEffect(() => {
    void refresh();
    const timer = window.setInterval(() => void refresh(), intervalMs);
    return () => window.clearInterval(timer);
  }, [intervalMs, refresh]);

  return state;
}
Enter fullscreen mode Exit fullscreen mode

The console.info calls are placeholders for the application’s structured logging adapter. Do not ship raw browser logs as your accounting system. The server should count accepted snapshots and rejected requests in its own telemetry, then join those events to a pipeline run through an explicit run_id or trace_id where that relationship exists.

There is a small but important cleanup detail: the sample passes an AbortSignal into fetch, and the effect clears its interval. That prevents a refresh from continuing after unmount. Test the lifecycle, too. Render before the first response, accept a complete response, reject an invalid response, and retain the previous accepted value after a later failure.

Turn structured logs into a cost report

Searchability starts before storage. Emit one JSON object per event, use an ISO timestamp, keep field names stable, and make outcome a closed set. A nightly run can then be summarized by runId, while a flag refresh can be summarized by service and outcome. The report should show where cost was attributed, not only how many requests happened.

For example, group pipeline records by runId, sum bytesRead and durationMs, and keep the unit visible. A number called cost is ambiguous if one team means provider requests and another means compute seconds. Store the measured unit beside the value or derive currency in a separate reporting layer whose pricing assumptions are versioned.

Prometheus instrumentation guidance is especially relevant here: labels with unbounded values create high cardinality. Do not label a counter with run_id, user ID, transaction ID, raw URL, or a full flag name set. Put those values in structured logs with access controls when they are needed for investigation; keep metrics aggregated by service, operation, and bounded outcome.

The failure modes are familiar. A retry storm inflates request volume. A schema change makes every snapshot invalid. A log pipeline drops records during the exact window you need to explain. A successful HTTP response is counted as success before validation. The fix is boring and effective: bounded retries, schema validation, an explicit stale state, a health signal for ingestion, and a reconciliation check that compares expected runs with observed runs. In a real nightly run, that reconciliation should happen after the final batch rather than after the first successful write: compare the scheduler's expected runId with the records observed by the log sink, compare the row count in the completion record with the count reported by the worker, and preserve the measurement unit used for attribution. If the flag adapter refreshed five times while the job was active, those events may explain frontend behavior, but they do not prove that the pipeline consumed five times the data. Conversely, a missing completion record should not be hidden by a healthy-looking refresh counter. Keep the two streams queryable on their own, then join them only on an explicit relationship. That distinction is what makes a cost review defensible when someone asks why a run was charged, retried, or marked incomplete.

Short logs. Clear joins.

Where does this design stop being suitable?

The catch is that polling and fallback config solve runtime presentation, not feature governance. This design is not suitable when you need per-user experiment statistics, approval history, parent-child dependencies, or authorization. It also does not make a frontend decision secret. A user can inspect the bundle and change client state.

Stick with bundled configuration when deployment-time changes are enough. Move evaluation and enforcement to the server when the flag gates money, permissions, or protected records. Choose a dedicated feature-management workflow when auditability and experiment analysis outweigh the simplicity of an app-owned adapter.

For the nightly pipeline, stop using a flag-refresh log as a proxy for job health. A heartbeat says that a scheduled job ran; a validated pipeline record says what it processed; a cost report says how usage was attributed. Those are three different questions. Keep them separate, then connect them with stable identifiers and retention rules.

References

Top comments (0)