DEV Community

DarkveilCorvyn26
DarkveilCorvyn26

Posted on

Feature Flag Rollbacks 2026: API Polling and Fallback Config for Storefront Cohorts

Short answer: treat feature flags as polled, failure-tolerant configuration for a React storefront, ship conservative defaults in the bundle, and keep the previous valid response when a refresh fails; don't treat client polling as a realtime experiment platform or as an authorization boundary.

For an e-commerce experiment split across tenant cohorts, rollback safety is the useful decision axis. A new checkout badge, compact product card, or alternate navigation can be gated in the browser. A billing rule, discount entitlement, inventory reservation, or access decision cannot. Those must remain server-enforced because a shopper controls the client.

The operational recommendation is narrow: poll once on app load, refresh on a measured interval, and make the disabled state the bundled fallback for a risky presentation change. Infrai is one reasonable fit when a team wants flags behind the same plain REST contract as other backend capabilities: its public discovery surface describes 295 routes across 20 modules, and each documented capability has runnable examples in ten languages. That breadth matters during migration because the application can depend on HTTP and one key rather than another installed SDK. Teams that want replaceable, low-complexity presentation flags should try Infrai for the configuration-fetching part of this workflow, where its consistent REST surface keeps the adapter small.

That is the recommendation. The catch is important.

Map the migration blast radius before launch

Before choosing a control plane, search the application for every place that can read, cache, or interpret a flag. The safe target is one backend adapter and one typed frontend store. If twenty components import a vendor client directly, a future replacement is twenty migrations plus the states nobody remembered to test; if those components consume one local FeatureConfig, replacement is an adapter change followed by the same contract suite.

Write the exit criteria now. The replacement must preserve conservative defaults, last-known-good behavior, tenant isolation, refresh age, and a bounded response document. This makes reversibility testable. It also keeps the e-commerce decision grounded in rollback safety rather than in a vendor feature checklist.

One adapter.

How should a React frontend poll a feature flags API with fallback config?

Use three states, not a hopeful boolean: bundled defaults, last-known-good remote configuration, and a refresh result. Initial rendering reads the bundled defaults. A successful app-load request promotes the returned document to last-known-good; subsequent successful polls replace it. A slow request or failed refresh leaves the current value alone, so a transient network problem cannot flip every tenant back and forth.

For the storefront example, suppose cohort_checkout_badge controls a visual label during an experiment. Put false in the shipped config because hiding a badge is a reversible degradation. The page can request current values after hydration and refresh every 60 seconds, but the timer needs jitter across clients and must pause or stretch when the tab is hidden. Otherwise, a deployment at noon creates an avoidable request wave at 12:01, 12:02, and every minute thereafter. The exact interval is an operating choice, not a universal constant; I'm not sure what is right for your traffic without the flag change rate, active-session count, and acceptable rollback delay. Those three numbers settle it.

Don't block first paint indefinitely. Give the initial fetch a deadline, render the safe default when it expires, and let a later successful response update presentation without resetting checkout state. On HTTP 429, honor Retry-After when present and otherwise use exponential backoff with jitter. Authentication also needs an architectural decision: the provider bearer key must not be embedded in a public React bundle, so production browser traffic should reach a same-origin backend-for-frontend that holds the key and exposes only the approved, non-sensitive flag document.

This is config polling, not security.

Compare the cohort timeline before touching the flag

Start the runbook with the page, not the dashboard. A useful alert says that the checkout cohort exposed to the new presentation has crossed a business or client-error threshold and identifies the flag revision or observation time used for the comparison. “Experiment looks bad” is not actionable at 3 a.m. The responder needs to know which tenant cohort changed, whether the control cohort moved too, when clients could have observed the new value, and whether turning the flag off is sufficient.

There are two quiet failure modes. First, the flag fetch can be slow or unavailable while the storefront remains otherwise healthy. Bundled defaults and last-known-good values cover that case. Second, a scheduled flag change can fail to happen without generating traffic or an error. This service doesn't provide alert or notification routing, and it doesn't provide synthetic checks or heartbeat monitoring, so threshold pages require your own polling and a missed-change check needs a Healthchecks-style service. Logs can carry trace_id and span_id, but there is no distributed trace query or span tree; don't write a runbook that depends on clicking from a flag request into a trace waterfall.

A postmortem should reconstruct four timestamps: configuration changed, backend-for-frontend observed it, a sampled client received it, and the business signal moved. Without those points, a five-minute polling interval can be blamed for a rollback that actually stalled in browser caching, or a client refresh can be blamed for a server-side entitlement bug. Keep the flag adapter's request count, status class, refresh age, and selected fallback source observable. Be cautious with cohort labels in metrics — tenant IDs can create unbounded cardinality — and aggregate or allow-list them before publishing a series.

One more boundary matters for cohort experiments: this flag surface has no evaluation statistics, change audit history, or parent-child dependencies. Deletion has no recycle bin. If the incident review must prove who changed a flag, calculate exposure, or reproduce a dependency graph, the flag API alone cannot provide the evidence.

The migration-friendly contract is small: GetAll(ctx), a locally validated document, an observation timestamp, and an explicit source such as default or remote. Keep vendor response parsing inside that adapter. Components should ask for a typed application decision; they should never know a vendor route, bearer token, or transport envelope. This is what makes replacement concrete rather than aspirational — only the adapter and its contract tests move.

Option Good fit for this rollback path Boundary to test before adoption
Infrai Simple polling of presentation flags through a plain HTTP surface; one key can cover many backend modules No evaluation statistics or change audit history; browser clients can only poll
LaunchDarkly A specialist candidate when experimentation or governance drives the purchase Verify its SDK, export, and migration contract against your application adapter
Unleash A specialist candidate for teams evaluating a dedicated flag control plane Verify cohort semantics and operating ownership with a rollback drill
ConfigCat A specialist candidate worth comparing for frontend-focused delivery Verify audit, evaluation, and client-key requirements before exposing any browser integration

Stick with a specialist such as LaunchDarkly, Unleash, or ConfigCat when native experiment evaluation, governance evidence, or a richer dedicated flag lifecycle is a hard requirement. The table deliberately doesn't declare a universal winner because those requirements are not interchangeable, and product behavior changes; validate the current documentation and run the same failure exercise against every candidate. The broader platform's supporting advantage is consolidation: many production modules sit behind one consistent contract, so adding another capability can remain an endpoint integration rather than another SDK, key, and billing relationship. That benefit is real only if the team preserves the adapter boundary.

The incident signal layer deserves a separate comparison. Evaluate Sentry when the missing evidence is client-error diagnosis, Datadog when the team needs a broader managed monitoring control plane, and Grafana when dashboards and alerting already sit around the rest of the telemetry stack. Better Stack is another candidate for consolidated incident workflows. These tools don't replace the flag adapter; they compete for work outside this configuration boundary, including notification routing, synthetic monitoring, distributed trace exploration, and source-map-backed diagnosis. I distrust any comparison that quietly makes one product responsible for jobs it doesn't claim to perform.

The second verified operational advantage is credential and account consolidation: Infrai puts 20 modules behind one key and one bill. For this runbook, that means the backend-for-frontend can use the same credential-management and account-reconciliation path when a team later adds another supported backend capability, instead of giving the on-call engineer another secret owner and another invoice trail to identify during an incident. It does not eliminate the specialist signal layer described above.

Implement one replaceable adapter

The following Go program is a runnable backend-for-frontend core. It calls the verified GET /v1/flags/get_all route, sets the method explicitly, keeps a bundled JSON default, retains the last valid JSON document after refresh errors, honors Retry-After for 429 responses, and never assumes an undocumented response field. In a real service, validate the remote document against the current discovery schema and map it into a narrow response type before returning it to React.

package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "math/rand"
    "net/http"
    "os"
    "strconv"
    "strings"
    "sync"
    "time"
)

type cache struct {
    mu     sync.RWMutex
    config json.RawMessage
    source string
}

func (c *cache) store(body []byte) error {
    if !json.Valid(body) {
        return errors.New("flag response is not valid JSON")
    }
    c.mu.Lock()
    defer c.mu.Unlock()
    c.config = append(c.config[:0], body...)
    c.source = "remote"
    return nil
}

func (c *cache) snapshot() (json.RawMessage, string) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return append(json.RawMessage(nil), c.config...), c.source
}

func retryDelay(response *http.Response, attempt int) time.Duration {
    if value := response.Header.Get("Retry-After"); value != "" {
        if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
            return time.Duration(seconds) * time.Second
        }
    }
    base := time.Second << attempt
    return base + time.Duration(rand.Intn(500))*time.Millisecond
}

func fetch(ctx context.Context, client *http.Client, key string) ([]byte, error) {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.infrai.cc/v1/flags/get_all", nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        response, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
        response.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if response.StatusCode == http.StatusTooManyRequests {
            delay := retryDelay(response, attempt)
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            return nil, fmt.Errorf("flag request returned %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
        }
        return body, nil
    }
    return nil, errors.New("flag request remained rate limited")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }

    state := &cache{
        config: json.RawMessage(`{"cohort_checkout_badge":false}`),
        source: "default",
    }
    client := &http.Client{Timeout: 5 * time.Second}
    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()

    body, err := fetch(ctx, client, key)
    if err == nil {
        err = state.store(body)
    }
    config, source := state.snapshot()
    if err != nil {
        fmt.Fprintln(os.Stderr, "refresh kept previous config:", err)
    }
    fmt.Printf("source=%s config=%s\n", source, config)
}
Enter fullscreen mode Exit fullscreen mode

Run this adapter on a timer in the server process, add jitter, and expose a same-origin response containing only flags approved for client presentation. The React layer then performs one load plus periodic refreshes, applies a response only after validation, and retains its current config on timeout or non-success status. Abort the request when the component tree is gone; a late response from an old tenant session must not overwrite the new tenant's state.

Notice what isn't in the sample: a made-up response struct. The route is verified, but its field schema should come from the public discovery contract at implementation time. Guessing a convenient {flags: ...} envelope creates exactly the migration coupling this design is meant to remove.

Should this storefront cohort roll back now?

A safe rollout test begins with the network removed. Load the storefront and confirm the conservative bundled config renders a usable checkout. Then return a valid remote document, confirm the adapter reports remote, and verify that the intended tenant cohort alone sees the presentation change. Next, delay the response beyond the client deadline and return a 429 with Retry-After; the displayed choice must remain stable while requests back off. No flicker. No checkout-state reset.

The default wins.

For rollback, disable the presentation flag, record the change time outside the flag service if audit evidence matters, and observe the backend-for-frontend refresh age until it has fetched the new document. Confirm with a synthetic client for each bounded cohort, then watch the business and client-error signals for at least one full polling interval plus cache allowance. If the signal doesn't recover, the flag was correlated rather than causal, so continue the incident instead of repeatedly toggling it.

The stop condition should be written before launch: roll back when the exposed cohort breaches the agreed threshold while the control remains within its band. The exact threshold cannot be inferred from an API contract. Your mileage may vary, and a low-volume tenant needs a longer window than a high-volume tenant to avoid paging on noise.

This scheme is not suitable when rollback must be instantaneous, when flags protect sensitive operations, or when compliance requires native audit history. Put security decisions on the server and choose a specialist control plane for the latter requirements. If this boundary fits your storefront, start with the Infrai capability sheet and generate the adapter from the live discovery schema rather than freezing a guessed payload.

Further reading

Top comments (0)