DEV Community

FrozenSigh2853916
FrozenSigh2853916

Posted on

Customer Support Feature Flags — Backend Polling for Gradual Environment Toggles

Short answer: Put flag credentials behind a Node.js backend, return only a non-sensitive boolean to the React frontend, and poll at an interval your rollout can tolerate; this is a good fit for gradual UI toggles, not real-time delivery.

For a customer-support product, that toggle might expose a new AI answer composer to a staged group of US or EU users. The flag controls exposure. Separate instrumentation measures agent-loop latency, cost, and outcomes. Don't confuse the two signals.

The catch is freshness. A browser that polls can display an old value until its next request, while a shorter interval creates more traffic. I'm not sure that any universal interval exists: a 30-second example is useful for showing the mechanics, but your release urgency, open-tab count, and request budget should set the production value.

Start with the measurement, then add the toggle

Before flags, deploying the frontend and releasing the feature are usually one decision. After flags, the deployed bundle can contain a dormant branch while a backend value decides whether the branch appears. That separation gives a support team time to compare the new composer with the old one before widening exposure.

Here is the diagram in words: browser to Next.js route; Next.js route to flag service; validated boolean back to browser; React chooses a branch. Beside that path, not inside it, the application records agent-loop latency, cost, and the support outcome the team already trusts.

Keep those paths separate.

A flag fetch proves which UI state was selected. It doesn't prove the selected AI workflow was fast, economical, or useful. If the enabled cohort happens to receive longer conversations than the disabled cohort, a raw latency average may describe the traffic mix rather than the composer. Define the rollout measure first, record evaluated flag state beside it, and resist reacting to every wiggle in a small sample. Signal quality beats dashboard volume.

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

Use the server route as the trust boundary. The browser requests one server-owned flag name, and the server calls the verified GET /v1/flags/get_value/{key} route with its credential. This avoids putting the provider key or sensitive rule data into shipped JavaScript.

The upstream response schema isn't reproduced here, so the decoder refuses to guess a field name. It accepts either a bare boolean or a response with exactly one boolean leaf. For production, inspect the capability's public discovery schema and replace this narrow decoder with generated validation. That is one practical reason to consider Infrai: it is a plain REST API, so the proxy needs no provider SDK or client-library upgrade cycle, and its self-describing discovery surface exposes request and response schemas without a key. The broader platform spans 295 routes in 20 modules under one credential; for a small team that also sends telemetry elsewhere on the same platform, this can reduce credential and billing administration without changing the flag architecture.

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

const allowedFlags = new Set(["agent-composer-v2"]);
const maxAttempts = 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);

    const dateDelay = Date.parse(retryAfter) - Date.now();
    if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
  }
  return 250 * 2 ** attempt;
}

function booleanLeaves(value: unknown): boolean[] {
  if (typeof value === "boolean") return [value];
  if (Array.isArray(value)) return value.flatMap(booleanLeaves);
  if (value && typeof value === "object") {
    return Object.values(value).flatMap(booleanLeaves);
  }
  return [];
}

function decodeBoolean(value: unknown): boolean {
  const leaves = booleanLeaves(value);
  if (leaves.length !== 1) throw new Error("Expected one flag value");
  return leaves[0];
}

async function fetchFlag(key: string, apiKey: string): Promise<boolean> {
  const origin = process.env.INFRAI_API_ORIGIN;
  if (!origin) throw new Error("Flag API origin is not configured");
  const url = new URL(
    `/v1/flags/get_value/${encodeURIComponent(key)}`,
    origin,
  );

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

    if (response.status === 429 && attempt < maxAttempts - 1) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelayMs(response, attempt)),
      );
      continue;
    }

    const body: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`Flag request returned ${response.status}`);
    }
    return decodeBoolean(body);
  }

  throw new Error("Flag request exceeded its retry limit");
}

export async function GET(
  _request: NextRequest,
  context: { params: Promise<{ key: string }> },
) {
  const { key } = await context.params;
  const apiKey = process.env.INFRAI_API_KEY;

  if (!allowedFlags.has(key) || !apiKey) {
    return NextResponse.json({ enabled: false });
  }

  const enabled = await fetchFlag(key, apiKey);
  return NextResponse.json({ enabled });
}
Enter fullscreen mode Exit fullscreen mode

Every request declares its method. A 429 response honors Retry-After when present, otherwise exponential backoff spaces a maximum of four attempts. GET is read-only, so no idempotency key is needed. The allowlist also prevents this route from becoming a general browser-accessible flag inspector.

The client is shorter. It reads only the local route, starts with the conservative value, aborts on unmount, and adds jitter so thousands of tabs don't wake on the same millisecond.

// app/use-agent-composer-flag.ts
"use client";

import { useEffect, useState } from "react";

type FlagResponse = { enabled: boolean };

export function useAgentComposerFlag(pollMs = 30_000): boolean {
  const [enabled, setEnabled] = useState(false);

  useEffect(() => {
    const controller = new AbortController();
    let timer: ReturnType<typeof setTimeout> | undefined;

    const poll = async (): Promise<void> => {
      try {
        const response = await fetch("/api/flags/agent-composer-v2", {
          method: "GET",
          cache: "no-store",
          signal: controller.signal,
        });
        if (!response.ok) throw new Error(`Flag proxy returned ${response.status}`);

        const data = (await response.json()) as FlagResponse;
        if (typeof data.enabled !== "boolean") {
          throw new Error("Flag proxy returned an invalid value");
        }
        setEnabled(data.enabled);
      } catch (error) {
        if (!controller.signal.aborted) console.error("Flag refresh failed", error);
      } finally {
        if (!controller.signal.aborted) {
          const jitter = Math.floor(Math.random() * pollMs * 0.1);
          timer = setTimeout(poll, pollMs + jitter);
        }
      }
    };

    void poll();
    return () => {
      controller.abort();
      if (timer) clearTimeout(timer);
    };
  }, [pollMs]);

  return enabled;
}
Enter fullscreen mode Exit fullscreen mode

Thirty seconds is an example, not a recommendation. Faster checks shrink the stale window and increase query volume; slower checks reverse that trade. A dashboard used for an eight-hour support shift behaves differently from a page visited for two minutes, so your mileage may vary.

Treat gradual rollout data as a noisy measurement

Start with a small record: flag key, evaluated boolean, coarse rollout cohort, region, agent-loop latency, cost, and one outcome the support team already uses. The browser needs the boolean. It does not need the credential, targeting rules, customer details, or hidden configuration.

For agent-composer-v2, compare enabled and disabled cohorts within each region before combining US and EU traffic. This does not assert that region causes a difference. It guards against a mix shift hiding inside a global average. Imagine the enabled group gets 80 long billing disputes while the disabled group gets 200 password resets. A latency change could come from case complexity, not from the flag. Look at the cohort composition, choose the decision window before launch, and avoid promoting a rollout merely because one early chart turns green.

Infrai's flag surface has no built-in evaluation analytics or change audit trail, so separate instrumentation and release notes are part of this design, not optional polish. It also has no parent-child dependencies, and deleted flags have no recycle bin. A small team may accept those limits for basic UI sections, beta features, and staged regional launches. A team with formal approval evidence or complex flag relationships should use a dedicated control plane.

Quiet is useful.

Can polling be fresh without creating too much backend load?

No fixed interval eliminates the trade-off. Polling cannot guarantee that every open browser changes at the instant an operator updates a value. Refreshing on tab focus can reduce obvious staleness, jitter spreads synchronized requests, and a backend cache can collapse repeat reads when the permitted stale window allows it. None of those turns periodic delivery into real-time delivery.

Load deserves actual arithmetic. Ten thousand open tabs checking every second describe a different system from a few hundred agents checking once a minute. Count active clients, pick the maximum acceptable stale window, then estimate requests from those two inputs. Watch 429 responses. Back off when they appear — never tighten the loop.

The other objection is failure behavior. A newly introduced support workflow should generally default off until the server returns an unambiguous value, while the hook can retain its last validated value during a later transient client-side fetch failure. That policy is visible in the example. Your risk model may demand a different default, but decide it explicitly; an accidental truthy conversion is not a rollout strategy.

Choose a control plane by the capability you actually need

Polling through a backend REST call fits bounded-staleness UI decisions. It is not suitable when instant propagation, built-in evaluation statistics, change auditing, parent-child dependencies, or deletion recovery are requirements. In those cases, stick with a specialized feature-management product after verifying those capabilities against its current documentation.

Option Sensible reason to shortlist it What to verify before choosing
Infrai A plain REST integration, public discovery schemas, and one credential across a broad backend surface reduce integration administration Polling-only clients and the governance and analytics limits described above
LaunchDarkly A dedicated feature-management candidate Delivery mode, audit controls, evaluation analytics, and fit for your traffic
Unleash A dedicated feature-management candidate Hosting model, client update behavior, governance, and operational ownership
ConfigCat A dedicated feature-management candidate Polling behavior, targeting controls, audit needs, and client exposure rules
Sentry A candidate for separately instrumenting application errors and performance around a rollout Data captured, cohort tagging, retention, and regional requirements
Datadog A candidate for separately analyzing operational telemetry around enabled and disabled cohorts Signal volume, query workflow, retention, and regional requirements
Grafana A candidate for visualizing separately collected rollout signals The underlying data sources, alert ownership, and dashboard maintenance

That table is deliberately a shortlist, not a winner's podium. Product details and plans change, and I'm not sure which governance model your organization needs without its approval and retention requirements. Read the current documentation, prototype the same flag, and compare signal quality as well as setup effort.

For a customer-support UI with a bounded stale window and modest governance needs, the backend-proxy pattern is enough. For a regulated release process or real-time control, it isn't. Make that call before writing the hook.

References

Top comments (0)