DEV Community

RemingtonCross5246
RemingtonCross5246

Posted on

Frontend Feature Flags: 3 Rollback Invariants for React and Next.js Polling Clients

For React or Next.js frontend feature flags, a simple polling client is useful only if a bad notification release can be stopped without making delivery state harder to reason about. I would choose a free, dependency-free implementation with three invariants: the last known valid snapshot remains usable, stale snapshots expire, and an older response can never replace a newer one. That is the short answer. It preserves a weekly shipping cadence without turning a cheap feature toggle into another service to babysit.

Choice Rollback behavior Operational burden Best fit
Poll a versioned snapshot Bounded by the poll interval Low Ordinary UI controls
Stream changes Fast, with reconnect states Higher Very short rollback targets
Build-time values Requires another deployment Lowest at runtime Rarely changed flags

Recommendation: poll a small, versioned snapshot for notification controls, but keep the server authoritative for actual delivery. The browser may hide a control or select copy. It must never grant permission to send, bypass suppression, or decide whether a recipient is eligible.

How should a React or Next.js feature flags polling client roll back?

A boolean looks simple until two requests finish out of order. Suppose snapshot version 42 disables a new digest composer. The next scheduled poll starts, then a tab becomes busy. Another request obtains version 43 and finishes first. If the older request later overwrites it with version 42, the client has moved backward even though every response was individually valid.

Tie freshness to a monotonic version, not arrival time. Accept a snapshot only when its version is greater than the stored version. This is the first invariant.

The second invariant is a bounded stale window. A temporary network failure should not make the interface thrash. Keep the last validated snapshot while it is fresh. Once its explicit expiry passes, use a conservative default that avoids exposing an unconfirmed notification path.

Fail closed.

The third invariant is schema validation before publication. Treat a successful fetch as untrusted input until the complete snapshot has the expected version, expiry, and boolean values. Partial adoption can create a combination that no server ever published.

These rules optimize for rollback safety, not the shortest snippet. The extra code earns its keep because a solo operator has a finite revenue-per-hour budget. An hour spent diagnosing a tab that resurrected an old notification flow is an hour not spent shipping. The trade-off is plain: this implementation has more state than reading a boolean, but every state has a defined rollback meaning.

No magic.

Two criteria matter more than polling speed

The first criterion is authority. Browser flag state should be advisory. The notification API must repeat the decisive checks: workspace eligibility, recipient consent, suppression, idempotency, and server-side rollout state. Turning off the server decision then stops new sends even while open tabs wait for another poll. This is also the main limitation of a frontend toggle: it can improve the interface, but it cannot safely authorize an external effect. Treating it as permission would make a polished React component the weakest link in notification delivery.

The second is diagnosability without needless identity data. Record the snapshot version and evaluated flag name beside a delivery outcome, rather than copying the complete document or recipient profile into every event. A compact event can say that version 43 selected digest_composer_v2 and that the server rejected the send because rollout was disabled.

Event grouping needs an explicit key. Sentry documents that events are grouped into issues and that a fingerprint can alter grouping. The broader lesson is vendor-independent: group failures by a stable operational cause, not volatile message text or recipient identifiers. notification.delivery_rejected plus a bounded reason such as rollout_disabled is more useful than an error containing an email address.

Erasure belongs in the design. GDPR Article 17 defines a right to erasure under stated conditions. If observability records contain personal data, deletion becomes system behavior rather than a cleanup note. Keeping recipient data out of evaluation events reduces what must be located; any necessary lookup key still needs a documented retention and deletion path.

A small client with monotonic snapshots

This TypeScript implementation has one timer, one request, a version check, full validation, expiry, and subscriptions. It stays free of vendor SDKs and is cheap to operate because it fetches a compact document at a fixed interval. The endpoint is pseudonymous. A React hook or Next.js client component can subscribe to the store without owning the polling lifecycle.

type FlagName = "digest_composer_v2" | "delivery_status_panel";

type Snapshot = {
  version: number;
  expiresAt: number;
  flags: Record<FlagName, boolean>;
};

const defaults: Snapshot = {
  version: 0,
  expiresAt: 0,
  flags: { digest_composer_v2: false, delivery_status_panel: false },
};

function isSnapshot(value: unknown): value is Snapshot {
  if (typeof value !== "object" || value === null) return false;
  const item = value as Partial<Snapshot>;
  const flags = item.flags as Partial<Record<FlagName, unknown>> | undefined;
  return Number.isSafeInteger(item.version) &&
    (item.version ?? -1) >= 0 &&
    Number.isFinite(item.expiresAt) &&
    (item.expiresAt ?? -1) >= 0 &&
    typeof flags?.digest_composer_v2 === "boolean" &&
    typeof flags?.delivery_status_panel === "boolean";
}

export class FlagStore {
  private snapshot = defaults;
  private listeners = new Set<() => void>();
  private timer: ReturnType<typeof setTimeout> | undefined;
  private controller: AbortController | undefined;

  constructor(
    private readonly url: string,
    private readonly intervalMs = 30_000,
  ) {}

  read(name: FlagName, now = Date.now()): boolean {
    return now < this.snapshot.expiresAt
      ? this.snapshot.flags[name]
      : defaults.flags[name];
  }

  version(): number {
    return this.snapshot.version;
  }

  subscribe(listener: () => void): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  start(): void {
    if (!this.timer && !this.controller) void this.poll();
  }

  stop(): void {
    if (this.timer) clearTimeout(this.timer);
    this.timer = undefined;
    this.controller?.abort();
    this.controller = undefined;
  }

  private async poll(): Promise<void> {
    this.controller = new AbortController();
    try {
      const response = await fetch(this.url, {
        cache: "no-store",
        signal: this.controller.signal,
      });
      if (!response.ok) throw new Error(`Flag fetch failed: ${response.status}`);
      const candidate: unknown = await response.json();
      if (!isSnapshot(candidate)) throw new Error("Invalid flag snapshot");
      if (candidate.version > this.snapshot.version) {
        this.snapshot = candidate;
        this.listeners.forEach((listener) => listener());
      }
    } catch (error: unknown) {
      if (!(error instanceof DOMException && error.name === "AbortError")) {
        reportPollFailure({ version: this.snapshot.version });
      }
    } finally {
      this.controller = undefined;
      this.timer = setTimeout(() => void this.poll(), this.intervalMs);
    }
  }
}

function reportPollFailure(event: { version: number }): void {
  void event;
}
Enter fullscreen mode Exit fullscreen mode

The server publishes each snapshot atomically. Here is example data, not a prescribed expiry policy:

{
  "version": 43,
  "expiresAt": 1791763200000,
  "flags": {
    "digest_composer_v2": false,
    "delivery_status_panel": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Choose expiry and polling intervals from the rollback objective. A 30-second interval is an implementation example, not a universal target: it permits up to one interval before a healthy open tab observes a newly published snapshot, while server enforcement can act on its own current state. Then use a fake clock to test four cases: retain a valid snapshot through a failed poll, reject version 42 after 43, fall back after expiry, and emit one update for a newer valid snapshot. These tests cover more rollback risk than mocking one successful fetch, and the version pair makes an out-of-order failure reproducible instead of vague.

Keep the framework boundary thin. A UI hook should subscribe to this store and read a named value; server-rendered code should receive initial state through a trusted path. Polling does not belong in each component. Multiple timers create duplicate traffic and can show different versions on one screen. One store per browser context is easier to stop, observe, and replace.

When is the runner-up better?

Polling has limits. Streaming is better when the allowed rollback delay is shorter than a practical polling interval. It adds connected, disconnected, reconnecting, and caught-up states. If the team cannot state what the UI does in each state, streaming has moved the uncertainty. Keep monotonic versions and expiry so a reconnect cannot publish an older snapshot. Polling is also unsuitable for per-request authorization or experiments whose assignment must remain identical across several devices; those decisions need an authoritative server-side record.

Build-time values win when a toggle changes only with a release and waiting for deployment is acceptable. They remove fetching, timers, and stale-state handling. The trade is a slower operational rollback. For a notification kill switch, coupling delivery control to code deployment is often too restrictive.

A direct server check is better for the final send decision. It costs a request on the action path because the authoritative system must evaluate current policy when it can create an external effect. Browser polling still improves interface behavior. It does not become a security boundary.

Ship the small client when its bounded delay matches the business risk. Keep delivery authority on the server, test versions out of order, and expose expiry in operational events. Those constraints are easier for one person to operate while shipping every week.

Sources

Top comments (0)