Handling Slow Network Conditions in Client SDKs

This how-to is part of Securely Passing Flags to the Browser. When a client SDK cannot reach the flag endpoint within the user’s first paint window, the fallback behavior determines whether the experience degrades gracefully or collapses. The strategy here is layered: an inlined bootstrap from the server means the SDK starts with valid state even before any network call; a hard timeout prevents the live-update fetch from hanging indefinitely; a circuit breaker stops hammering a degraded endpoint; and local storage provides continuity on subsequent visits.

Client SDK resilience layers for slow networks Four concentric layers of resilience: server bootstrap, local storage fallback, circuit breaker, and ETag-based conditional fetch. Layer 1 — Server bootstrap (zero latency) Layer 2 — localStorage last-known-good Layer 3 — Circuit breaker (3 failures → open 5 s) Layer 4 — ETag conditional fetch (skip unchanged payloads) live flag endpoint · 2 s hard timeout · 2 retries with jitter
Resilience is layered: the server bootstrap provides instant state; local storage covers offline; the circuit breaker stops retry storms; ETag skips unchanged payloads.

Prerequisites

Prerequisites for slow-network resilience A wired client SDK, an embedded bootstrap payload, a live endpoint that supports ETag conditional fetches, and local storage for last-known-good state. Client SDK wired to app Bootstrap payload embedded in HTML ETag endpoint If-None-Match localStorage last-known-good
The bootstrap covers first paint; the ETag endpoint and local storage together keep later updates cheap and resilient.

Step-by-Step Implementation

The four techniques form a resilience ladder: cap the wait, fall back to cache, stop retry storms, then skip unchanged payloads.

The four resilience techniques Enforce a hard fetch timeout, fall back to localStorage for last-known-good state, add a circuit breaker to stop retry storms, then use ETag conditional fetches to skip unchanged payloads. 1 Hard timeout cap the wait 2 Cache fallback last-known-good 3 Circuit breaker stop storms 4 ETag fetch skip unchanged
Steps 1–2 keep the UI responsive under a stalled network; steps 3–4 keep it from hammering a struggling endpoint.

Step 1 — Enforce a hard fetch timeout

The single highest-impact change: abort the live-update fetch after a fixed window so a slow endpoint never blocks client SDK initialization. Two seconds covers the 99th percentile of mobile RTT on 3G while still failing fast.

// client/flagFetch.ts
export async function fetchFlagsWithTimeout(
  url: string,
  timeoutMs = 2000,
  signal?: AbortSignal,
): Promise<Record<string, boolean> | null> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort('timeout'), timeoutMs);
  // Respect upstream abort (React useEffect cleanup, navigation, etc.)
  signal?.addEventListener('abort', () => controller.abort());

  try {
    const res = await fetch(url, { signal: controller.signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return (await res.json()).flags as Record<string, boolean>;
  } catch (err) {
    if ((err as Error).name === 'AbortError') {
      console.warn('web.checkout.flag-fetch: timed out after', timeoutMs, 'ms');
    }
    return null; // signal to caller: use fallback
  } finally {
    clearTimeout(timer);
  }
}

Return null on any failure so the caller can immediately activate the fallback path without distinguishing error types.

Step 2 — Fall back to localStorage for last-known-good state

When the live fetch fails or times out, serve the most recently cached payload from localStorage. This keeps returning visitors in a known-good state even when the flag endpoint is unreachable.

// client/flagCache.ts
const CACHE_KEY = 'ff:web.checkout:flags';
const CACHE_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes

interface CachedFlags {
  flags: Record<string, boolean>;
  cachedAt: number;
}

export function readCachedFlags(): Record<string, boolean> | null {
  try {
    const raw = localStorage.getItem(CACHE_KEY);
    if (!raw) return null;
    const { flags, cachedAt } = JSON.parse(raw) as CachedFlags;
    if (Date.now() - cachedAt > CACHE_MAX_AGE_MS) return null; // stale
    return flags;
  } catch {
    return null;
  }
}

export function writeCachedFlags(flags: Record<string, boolean>): void {
  try {
    localStorage.setItem(CACHE_KEY, JSON.stringify({ flags, cachedAt: Date.now() }));
  } catch {
    // localStorage full or in a private-browsing context — silently skip
  }
}

Cap the cache age at 5 minutes to prevent stale flags persisting across a major rollout. Adjust to your deployment cadence.

Step 3 — Add a circuit breaker to stop retry storms

After three consecutive failures, stop issuing requests for a reset window. This prevents a degraded flag endpoint from receiving a thundering herd of retries from every active tab.

// client/circuitBreaker.ts
const STATE_KEY = 'ff:cb:web.checkout';

interface BreakerState {
  failures: number;
  openUntil: number; // epoch ms; 0 = closed
}

export function isCircuitOpen(): boolean {
  try {
    const raw = sessionStorage.getItem(STATE_KEY);
    if (!raw) return false;
    const { openUntil } = JSON.parse(raw) as BreakerState;
    return Date.now() < openUntil;
  } catch {
    return false;
  }
}

export function recordFailure(): void {
  try {
    const raw = sessionStorage.getItem(STATE_KEY);
    const state: BreakerState = raw ? JSON.parse(raw) : { failures: 0, openUntil: 0 };
    state.failures += 1;
    if (state.failures >= 3) {
      state.openUntil = Date.now() + 5_000; // open for 5 seconds
    }
    sessionStorage.setItem(STATE_KEY, JSON.stringify(state));
  } catch { /* */ }
}

export function recordSuccess(): void {
  sessionStorage.removeItem(STATE_KEY);
}

Use sessionStorage (not localStorage) so the breaker resets across tabs and after a page reload — avoiding a stuck-open breaker that silently serves stale state indefinitely.

Step 4 — Use ETag conditional fetches to skip unchanged payloads

Most flag-update fetches will return the same payload the client already has. An If-None-Match header lets the server return 304 Not Modified, which costs only a single RTT with no body transfer — crucial on slow connections.

// client/flagFetchConditional.ts
export async function fetchFlagsConditional(
  url: string,
): Promise<Record<string, boolean> | null> {
  const cachedEtag = sessionStorage.getItem('ff:etag:web.checkout');
  const headers: Record<string, string> = {};
  if (cachedEtag) headers['If-None-Match'] = cachedEtag;

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort('timeout'), 2000);

  try {
    const res = await fetch(url, { signal: controller.signal, headers });
    clearTimeout(timer);

    if (res.status === 304) {
      return readCachedFlags(); // unchanged — serve from cache
    }
    if (!res.ok) throw new Error(`HTTP ${res.status}`);

    const { flags } = await res.json();
    const etag = res.headers.get('ETag');
    if (etag) sessionStorage.setItem('ff:etag:web.checkout', etag);
    writeCachedFlags(flags);
    return flags;
  } catch {
    clearTimeout(timer);
    return null;
  }
}

Combine Steps 2–4 in a single updateFlags() wrapper: check the circuit breaker first, attempt the conditional fetch, record success or failure, fall back to cache.

Verification

Resilience test sequence under throttling Under Slow 3G the app initializes from the bootstrap without flicker; blocking the endpoint loads the localStorage fallback; three failures open the circuit; re-enabling the endpoint closes it and refreshes flags. Slow 3G boot, no flicker block endpoint cache fallback 3 failures circuit opens re-enable circuit closes A unit test mocks fetch to time out and asserts cache init within 50 ms.
Each stage exercises one defense in isolation, so a regression points at exactly one technique.

Test resilience in DevTools before shipping:

# 1. Throttle to Slow 3G in DevTools → Network
# 2. Open the app — confirm flags initialize from the bootstrap with no flicker
# 3. Disable the flag endpoint (block the URL in DevTools → Network → Block request URL)
# 4. Reload — confirm localStorage fallback loads and no console errors other than the expected warning
# 5. Simulate 3 consecutive failures (keep the endpoint blocked) — confirm circuit opens
# 6. Re-enable the endpoint and wait 5s — confirm the circuit closes and flags refresh

Also add a unit test that mocks fetch to time out and asserts that the SDK initializes from the cached payload within 50ms of the timeout.

Gotchas & Edge Cases

Three slow-network edge cases Storage writes can throw in private browsing or when the quota is full, circuit-breaker state is per-tab, and the bootstrap goes stale after a session change. Storage can throw private mode / quota treat as cache miss Breaker is per-tab sessionStorage scope BroadcastChannel to share Stale on session change refresh after login / logout
Wrapping every storage write in try/catch is the single most common miss — a full quota should degrade to a cache miss, not a thrown error.

Troubleshooting & FAQ

The SDK initializes from the bootstrap, but the live update never lands — why?

The most common causes are: the circuit breaker is open (check sessionStorage for the ff:cb:* key), the fetch is being aborted by an upstream AbortSignal before the timeout fires (e.g. a React useEffect cleanup), or the endpoint is returning a non-2xx status that triggers the failure path. Log the return value of fetchFlagsWithTimeout to distinguish them.

How do I prevent UI flicker when the live update arrives after first render?

Treat the bootstrap state as authoritative for the first render pass. Apply live updates only if they differ from the bootstrap values, and avoid re-rendering flag-gated components unless the variant actually changed. A diffing step before calling setProvider (or the equivalent state setter) keeps the DOM stable.

Should I cache flags in sessionStorage or localStorage?

Use localStorage for the last-known-good fallback (persists across sessions, useful for returning visitors) and sessionStorage for the circuit breaker and ETag (session-scoped, avoids stale state carrying over to a new visit). Keep the localStorage entry short-lived (5–10 minutes max).