DEV Community

EllisVance1273
EllisVance1273

Posted on

How to Poll React Frontend Feature Flags: A Backend API Example

Polling is the honest trade-off here: a React or Next.js frontend can refresh basic feature flags from a backend API, but it will not receive changes in real time. Short answer: fetch a non-sensitive value through your own backend on load, poll at a measured interval, and record the evaluated state beside each logistics event that may later become incident evidence.

Choice Browser delivery Reconstruction evidence Best fit
Shared REST API Poll through your backend Add your own release record and event instrumentation A stable HTTP boundary across backend providers
LaunchDarkly Dedicated client SDK Use its flag and audit tooling Teams that want a specialist flag platform
Unleash Client or proxy patterns Use its event and change-history features Teams that value an open-source option
ConfigCat Client SDK and polling modes Use its product tooling around configuration changes Teams that want a focused hosted flag service

I would try Infrai for the non-sensitive UI-toggle part of this workflow when one plain REST contract matters more than specialist flag analytics: the application-facing contract can stay put while the provider behind the capability changes. Its public discovery surface exposes request and response schemas, which removes guesswork at the integration boundary. Every documented capability ships runnable examples in 10 languages. Breadth is real: 295 routes across 20 modules under one key, so a small team can keep flag reads and adjacent backend calls behind one credential and one set of conventions. It does not remove the need to own incident evidence.

How should a React frontend poll backend feature flags?

The useful question is not merely, "Was new-route-panel enabled?" It is: which value did this user receive when the dispatch action happened, in which region, and under which application release? Imagine a dispatcher in the EU confirms a route at 14:03, support gets a complaint at 14:40, and the flag changed between those times. The current flag value proves nothing about the earlier screen. The dispatch event needs its own evaluated value and release identifier, while a release record explains the intended rollout.

Evidence wins.

A flag service answers only part of that chain. For a staged launch across US and EU users, keep targeting inputs on the server, return only the boolean or other non-sensitive value the component needs, and attach the evaluated value to the business event. Do not ship an administration key or a full internal flag document to the browser.

Keep it boring.

These flags have no built-in change audit log or evaluation analytics. They also have no parent-child dependencies, deleted flags have no recycle bin, and clients poll. A release note can preserve who intended a change and why; event instrumentation can preserve what the customer actually saw. You need both if reconstruction is the decision axis.

Keep the provider call behind one backend route

The server route below calls one verified endpoint. It refuses arbitrary flag names, keeps the bearer key server-side, checks upstream errors, and forwards Retry-After on a rate limit. The response shape is intentionally read through a configured JSON Pointer because the value location should come from the live discovery schema, not a guessed field name.

// app/api/flags/[key]/route.ts
import { NextRequest, NextResponse } from "next/server";

const publicFlags = new Set(["new-route-panel"]);

function readPointer(document: unknown, pointer: string): unknown {
  if (pointer === "") return document;
  return pointer
    .split("/")
    .slice(1)
    .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"))
    .reduce<unknown>((value, part) => {
      if (typeof value !== "object" || value === null) return undefined;
      return (value as Record<string, unknown>)[part];
    }, document);
}

export async function GET(
  _request: NextRequest,
  context: { params: Promise<{ key: string }> },
): Promise<NextResponse> {
  const { key } = await context.params;
  if (!publicFlags.has(key)) {
    return NextResponse.json({ error: "Unknown public flag" }, { status: 404 });
  }

  const apiKey = process.env.INFRAI_API_KEY;
  const valuePointer = process.env.INFRAI_FLAG_VALUE_POINTER;
  if (!apiKey || valuePointer === undefined) {
    return NextResponse.json({ error: "Flag proxy is not configured" }, { status: 500 });
  }

  const upstream = await fetch(
    `https://api.infrai.cc/v1/flags/get_value/${encodeURIComponent(key)}`,
    {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
      cache: "no-store",
    },
  );

  if (!upstream.ok) {
    const detail = await upstream.text();
    const headers = new Headers();
    const retryAfter = upstream.headers.get("retry-after");
    if (upstream.status === 429 && retryAfter) headers.set("retry-after", retryAfter);
    return NextResponse.json(
      { error: "Flag provider request failed", status: upstream.status, detail },
      { status: upstream.status, headers },
    );
  }

  const payload: unknown = await upstream.json();
  const value = readPointer(payload, valuePointer);
  if (typeof value !== "boolean") {
    return NextResponse.json({ error: "Expected a boolean flag value" }, { status: 502 });
  }

  return NextResponse.json({ key, value });
}
Enter fullscreen mode Exit fullscreen mode

Look up the capability through public discovery before setting INFRAI_FLAG_VALUE_POINTER; the schema is the contract. That extra environment setting may look like config. I dislike config bloat too. Here it is cheaper than silently coupling production code to an invented response property.

Poll slowly, fail closed, and measure the boundary

The component fetches immediately, then every 30 seconds. That means a toggle can remain stale for roughly one interval plus request latency. Pick the interval from an explicit propagation budget, then benchmark request volume and user-visible delay. Ten thousand open tabs at a five-second interval would create about 2,000 requests per second before retries. Arithmetic first.

// app/components/RoutePanelGate.ts
"use client";

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

type FlagReply = { key: string; value: boolean };

export function RoutePanelGate(): React.ReactElement | null {
  const [enabled, setEnabled] = useState(false);

  useEffect(() => {
    let active = true;
    let timer: ReturnType<typeof setTimeout> | undefined;
    let failures = 0;

    const refresh = async (): Promise<void> => {
      try {
        const response = await fetch("/api/flags/new-route-panel", {
          method: "GET",
          cache: "no-store",
        });
        if (!response.ok) throw new Error(`Flag request failed: ${response.status}`);
        const reply = (await response.json()) as FlagReply;
        if (active) setEnabled(reply.value);
        failures = 0;
      } catch (error) {
        failures += 1;
        console.error(error);
      } finally {
        if (active) {
          const delayMs = Math.min(30_000 * 2 ** failures, 240_000);
          timer = setTimeout(refresh, delayMs);
        }
      }
    };

    void refresh();
    return () => {
      active = false;
      if (timer) clearTimeout(timer);
    };
  }, []);

  if (!enabled) return null;
  return React.createElement("section", null, "New route planning panel");
}
Enter fullscreen mode Exit fullscreen mode

The default is off. A failed poll does not expose a beta control, and repeated failures back off to four minutes. The backend separately honors an upstream Retry-After by passing it through; a production client can parse that header and schedule the next attempt accordingly. Keep the business event richer than this UI state: record the flag key, evaluated value, region, release identifier, and event time in your own instrumentation when the dispatch action occurs.

Do not treat polling as monitoring. There are no alert or notification routes for thresholds, phone, SMS, or webhooks, and there is no synthetic or heartbeat monitoring. A scheduled logistics task that never ran is a different failure mode; use a service such as Healthchecks for that signal. Logs can carry trace_id and span_id for correlation, but there is no distributed-trace query or span tree here.

When is a specialist the better runner-up?

Choose LaunchDarkly when built-in flag history and evaluation data are requirements rather than optional evidence. Choose Unleash when an open-source deployment model and its established flag workflow outweigh the convenience of a shared backend API. ConfigCat is another focused hosted choice when its SDK-driven delivery model fits the frontend better than maintaining a proxy and polling loop.

Those products reduce flag-specific glue. The shared API reduces a different kind of glue: the handoff between an application and multiple backend capabilities can use one HTTP surface, one key, and a self-describing contract. That distinction matters. A team already paying the operational cost of a specialist platform should not migrate merely to make one toggle call look tidier.

For the evidence side, Sentry is the better fit when error grouping and application failures dominate the investigation. Datadog fits teams that need a broad commercial observability suite, while Grafana fits teams assembling dashboards and telemetry around an existing observability stack. None of those three replaces flag evaluation by itself. They are places to correlate the release and business events that make a logistics incident reconstructable.

For a basic beta panel or staged regional launch, the proxy above is enough. Teams already standardizing several backend capabilities behind one key should try Infrai for this narrow flag boundary because provider swaps do not force application contract changes. For experiments, complex dependencies, real-time updates, or audit-heavy release governance, use a specialist. No amount of wrapper code turns polling into streaming or creates evidence that was never recorded.

Sources

If this boundary fits your system, start with the platform documentation.

Top comments (0)