DEV Community

ViggoKnight2318
ViggoKnight2318

Posted on

React and Next.js Feature Flags Explained — A Simple Polling Client Guide

Short answer: use a small Next.js server route to fetch current flags, then let the React client poll that route on a modest interval. This is a good fit for low-stakes UI toggles, such as showing a beta badge or an optional AI-agent cost panel. It is not an instant kill switch, an authorization check, or a billing control.

For a fintech team measuring latency and cost across an AI agent loop, the practical target is boring: a flag named show_agent_cost_panel controls an optional dashboard panel, while the actual cost and access rules stay on the server. The browser may be one polling interval behind. Plan for that.

Security and staleness matrix

Start with the failure mode, not the logo. A delayed badge is tolerable. A delayed trading restriction isn't.

Option Pick it when Check before committing
A tiny polling client The toggle affects presentation, brief staleness is acceptable, and a separate system records rollout decisions Polling interval, request volume, browser lifecycle, and server-side enforcement
Infrai You already benefit from one key and one bill across backend services, and a plain REST call is preferable to adding another browser SDK Clients poll because flags have no realtime push, evaluation statistics, change audit log, parent-child dependencies, or deletion recovery
LaunchDarkly It is on your serious vendor shortlist Confirm its current client update model, audit history, evaluation data, and commercial terms in its current documentation
Unleash It is on your serious vendor shortlist Confirm the same requirements, plus the operating model your team is prepared to own
ConfigCat It is on your serious vendor shortlist Confirm the same requirements and test its client behavior under the network conditions you actually have

The last three rows are deliberately evaluation prompts, not feature claims. Vendor behavior and plans change. I don't think a responsible comparison should fill unknown cells with guesses; run a small proof against the same checklist instead. The decision is then reproducible: update speed, history, evaluation evidence, operational ownership, and whether a browser-visible value could cause financial harm.

Infrai's concrete appeal in this narrow design is consolidation — one key and one bill for backend capabilities — with a consistent REST interface that doesn't require a dedicated SDK. The catch is equally concrete: its client integration is polling-only, and teams that need flag evaluation statistics or a built-in audit trail should choose a tool whose currently documented behavior satisfies those requirements.

The flag provider is only half of an incident-reconstruction design. Sentry is relevant when event grouping and fingerprints are already central to the investigation. Put Datadog on the evaluation list when it is already the organization's approved operational workspace, and test whether your rollout marker can be correlated with the latency and cost evidence you retain. Evaluate Grafana when the team already reconstructs incidents through its dashboards; again, verify the marker workflow rather than assuming it. Better Stack is another real candidate to test against the same reconstruction exercise. These are not claims that the products are interchangeable with a flag service. They are choices for the evidence side of the system, and current product documentation should settle their fit.

How should a React Next.js frontend poll feature flags?

Put the credential on the server. The browser calls your same-origin Next.js route; that route calls the flag API with Authorization: Bearer <key>. Never ship the backend key in a NEXT_PUBLIC_ variable. That boundary matters more than the hook.

Here is the diagram in words: React component -> Next.js route -> flag API -> Next.js route -> React state. On mount, the hook fetches immediately. A timer starts only after that request finishes, so a slow request cannot create an overlapping pile. A 429 pauses according to Retry-After when the server provides it; otherwise, the retry delay grows exponentially. Every other non-success status becomes a real error rather than an accidental false flag.

Use one verified read route to bootstrap the UI: GET /v1/flags/get_all. Create this Route Handler as app/api/ui-flags/route.ts:

import { NextResponse } from "next/server";

const MAX_ATTEMPTS = 4;

function retryDelayMs(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
  }

  return 500 * 2 ** attempt;
}

function wait(milliseconds: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

export async function GET(): Promise<NextResponse> {
  const apiKey = process.env.INFRAI_API_KEY;
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!apiKey || !baseUrl) {
    return NextResponse.json(
      { error: "Flag API server configuration is incomplete" },
      { status: 503 },
    );
  }

  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
    const response = await fetch(`${baseUrl}/v1/flags/get_all`, {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
      cache: "no-store",
    });

    if (response.status === 429 && attempt < MAX_ATTEMPTS - 1) {
      await wait(retryDelayMs(response, attempt));
      continue;
    }

    const body: unknown = await response.json();
    if (!response.ok) {
      return NextResponse.json(
        { error: "Flag lookup failed", upstreamStatus: response.status, body },
        { status: 502 },
      );
    }

    return NextResponse.json(body, {
      headers: { "Cache-Control": "private, no-store" },
    });
  }

  return NextResponse.json({ error: "Rate limit retry budget exhausted" }, { status: 429 });
}
Enter fullscreen mode Exit fullscreen mode

The route passes through the successful JSON instead of inventing fields. Define a narrow decoder from the response schema exposed by the API's public discovery surface, then keep that decoder in one place. The example below uses a decoder argument for exactly that reason: the polling machinery remains runnable and type-safe without pretending that an undocumented field exists.

Create app/use-polled-flags.ts:

"use client";

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

type FlagState = Readonly<Record<string, boolean>>;
type Decoder = (payload: unknown) => FlagState;

type PollResult = {
  flags: FlagState;
  error: string | null;
  updatedAt: number | null;
};

export function usePolledFlags(
  decode: Decoder,
  intervalMs = 30_000,
): PollResult {
  const [flags, setFlags] = useState<FlagState>({});
  const [error, setError] = useState<string | null>(null);
  const [updatedAt, setUpdatedAt] = useState<number | null>(null);
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

  const poll = useCallback(async (): Promise<void> => {
    try {
      const response = await fetch("/api/ui-flags", {
        method: "GET",
        cache: "no-store",
      });
      const payload: unknown = await response.json();

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

      setFlags(decode(payload));
      setUpdatedAt(Date.now());
      setError(null);
    } catch (caught) {
      setError(caught instanceof Error ? caught.message : "Flag request failed");
    } finally {
      timer.current = setTimeout(() => void poll(), intervalMs);
    }
  }, [decode, intervalMs]);

  useEffect(() => {
    void poll();
    return () => {
      if (timer.current) clearTimeout(timer.current);
    };
  }, [poll]);

  return { flags, error, updatedAt };
}
Enter fullscreen mode Exit fullscreen mode

Keep decode stable with useCallback in the consuming component, and implement it from the discovered response schema. Then render flags.show_agent_cost_panel as a convenience, never as proof that the current user may see sensitive cost data. Thirty seconds is a starting interval, not a universal truth. I'm not sure it fits your traffic and rollout expectations until you calculate the added read volume and test the worst acceptable stale window.

Test the incident timeline before rollout

Polling answers, "What value can this browser see now?" Incident reconstruction asks a harder question: "Who intended which rollout, when, and what happened to the agent loop afterward?" With no built-in flag change audit log or evaluation statistics, those are separate records. Don't infer history from the current value.

For the fintech dashboard example, record rollout decisions in a system your team controls. A useful decision record has the flag key, old and new intended values, actor, approval reference, deployment identifier, and timestamp. Separately measure the AI agent loop's latency and cost. The join key should be a rollout or deployment identifier that both streams understand. This is a diagram in words too: decision record -> deployment marker -> latency and cost series -> incident timeline.

Be strict here.

Suppose the panel appears at 10:02, an agent latency graph rises at 10:04, and the on-call engineer opens an incident at 10:11. Browser polling alone proves none of the causal links. The client might have fetched at 10:01:59 and again at 10:02:29; another tab may have been suspended; the UI flag may only expose a panel while a server deployment changed agent behavior. A reconstruction should therefore line up explicit rollout intent, server-side behavior, and measurements rather than treating one screenshot as an audit trail. Sentry's event grouping and fingerprint documentation is a useful model for this general discipline: stable grouping choices affect what an investigator can reconstruct later.

This separation also keeps your feature flag clean. It chooses presentation. Observability explains consequences.

Capacity-plan the polling loop

Polling cost begins with request volume, not a vendor price card. Estimate requests per minute as active clients * 60 / interval seconds, then include retries and the fact that several open tabs may represent one person. At a 30-second interval, each continuously active client makes two scheduled reads per minute. Don't mistake that example for a recommended global interval; the right number comes from your tolerated stale window and expected concurrency.

The server proxy gives you one place to observe this traffic. Track its request rate, latency, non-success status count, and 429 count. A rising retry count tells you to revisit the interval or traffic shape. It does not justify removing backoff. Also decide what the UI does during a transient read error: keeping the last known low-stakes presentation value is often less disruptive than flickering, but sensitive behavior still belongs behind a fresh server-side decision.

Free or cheap flag reads can still be operationally noisy when multiplied by clients and tabs. Calculate first.

Migration triggers for leaving the simple client

Do not use this pattern when a stale value could authorize a transaction, change billing, expose private data, or delay an emergency stop. Keep those checks on the server, where the authoritative decision happens at request time. A hidden React component is not access control; anyone can inspect client code and traffic.

It is also a poor fit when the product or compliance team requires native flag history, evaluation counts, parent-child dependencies, or recovery after deletion. Those are capability boundaries, not implementation details the hook can repair. Stick with a dedicated flag option after verifying that its current documentation and your proof-of-concept meet those requirements. Your mileage may vary on the acceptable polling interval — foreground tabs, background tabs, network changes, and request volume all shape the answer — but the security boundary does not vary.

Finally, don't stretch this flag client into an alerting system. There are no flag push notifications, and the broader observability surface has no threshold, phone, SMS, or webhook alert route. Silent scheduled-work failures also need a heartbeat tool such as Healthchecks. Logs can carry trace_id and span_id for correlation, but there is no distributed trace query or span tree; crash symbolication, source-map decoding, and Session Replay are outside this design as well.

The smallest sound version has a short handoff checklist:

  1. Fetch flags through a server route so the API key never reaches the browser.
  2. Load immediately, poll after completion, and stop the timer on unmount.
  3. Honor Retry-After on 429, cap retries, and surface other status codes.
  4. Use flags for low-stakes rendering while enforcing sensitive decisions server-side.
  5. Record rollout intent separately, then correlate it with agent-loop latency and cost.
  6. Re-evaluate the vendor choice when realtime updates, audits, or evaluation statistics become requirements.

That's enough. The result is simple because its responsibility is narrow, not because feature flags are harmless.

References

Top comments (0)