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.
Prerequisites
@openfeature/web-sdk(or a compatible client SDK) installed and wired to your app- secure browser delivery)
ETagand acceptsIf-None-MatchlocalStorageavailable (orIndexedDBfor larger payloads)Slow 3Gfor local testing
Step-by-Step Implementation
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
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
- Private browsing and storage quota:
localStorage.setItemthrows in some private-browsing contexts and when the quota is full. Always wrap storage writes intry/catchand treat failures as a cache miss, not an error. - Multiple tabs: Each tab runs its own circuit breaker state in
sessionStorage, which is per-tab. If you need fleet-wide back-pressure, coordinate via aBroadcastChannelmessage or use a shared service worker. - Bootstrap stale after login/logout: The server-rendered bootstrap reflects the state at page load. After a session change, trigger a full flag refresh (or reload) so the new user’s variants replace the previous session’s state.
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).