1. You fire 3 APIs in parallel on page load. One is slow, two fail. How do you manage granular loading states, retries, and show partial UI without blocking the page?
Answer / Solution
- Keep per-request state (loading / success / error) instead of one global flag.
- Fire them in parallel but handle results individually —
Promise.allSettled()is perfect for this: it returns each promise outcome so you can render partial UI for the ones that succeeded and show errors for the failed ones. - Implement retries with exponential backoff and a max attempt count for transient errors. Only retry idempotent/safe requests.
- Render UI progressively: show skeletons/placeholders for slow data, show components for successful data immediately, and show error states or retry buttons for failures.
- Track request-level metadata (attempts, lastError) so UI can show “Retry” or “Try again later.”
Example (simplified):
// per-request fetch with retry
async function fetchWithRetry(url, opts = {}, retries = 3, delay = 500) {
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`${res.status}`);
return await res.json();
} catch (err) {
if (i === retries - 1) throw err;
await new Promise(r => setTimeout(r, delay * (2 ** i))); // exponential backoff
}
}
}
// on mount
useEffect(() => {
const p1 = fetchWithRetry('/api/a').then(data => setA({state:'ok', data})).catch(e=>setA({state:'err', e}));
const p2 = fetchWithRetry('/api/b').then(...).catch(...);
const p3 = fetchWithRetry('/api/c').then(...).catch(...);
Promise.allSettled([p1,p2,p3]); // ensures we don't block rendering
}, []);
2. User clicks "Buy Now" multiple times rapidly. Backend is not idempotent. How do you guarantee only one API call is sent? How do you guard against double submissions across tabs?
Answer / Solution
- Single-tab: Immediately disable the button after first click (UI-level prevention), and debounce or throttle the click handler. Also create and attach a client-side unique request token (UUID) to the request and store it while awaiting response — reject subsequent clicks if a pending token exists.
- Cross-tab: Use a cross-tab locking mechanism:
localStoragelock with expiry orBroadcastChannelto coordinate. On click, try to obtain the lock; only the tab that got the lock sends the request. Release lock on success/failure. - Server-side safety: Best practice — add server-side idempotency (idempotency key or transaction token) so duplicate requests with the same key are ignored or deduped. Client should generate and reuse the idempotency key for retries.
- On unload: You can use
navigator.sendBeaconor keep the UI disabled and poll for order status so other tabs know an order is in-flight.
Example (single-tab + cross-tab simplified):
// simple single-tab guard
let pending = false;
async function handleBuy() {
if (pending) return;
pending = true;
buyBtn.disabled = true;
try { await api.post('/buy', { idempotencyKey: uuid() }); }
finally { pending = false; buyBtn.disabled = false; }
}
// cross-tab via localStorage lock
function tryAcquireLock(key, ttl = 5000) {
const now = Date.now();
const lock = JSON.parse(localStorage.getItem(key) || 'null');
if (!lock || now - lock.ts > ttl) {
localStorage.setItem(key, JSON.stringify({ ts: now }));
return true;
}
return false;
}
3. Component A and B call the same API - but Component A gets stale data. How do you implement shared cache, avoid duplicate calls, and ensure data consistency?
Answer / Solution
- Use a shared request cache layer (singleton) that stores in-flight promises and cached results + timestamps (stale-while-revalidate). If a request for the same key is in-flight, return the same promise (dedupe). If cached and fresh, return cached value.
- Use libraries like React Query, SWR, or Apollo which implement deduping, caching, background revalidation, cache invalidation and subscriptions out of the box.
- When one component mutates data, invalidate or update the shared cache so other components get fresh values (optimistic update + revalidate). Use subscription pattern so components re-render when cache updates.
- Use versioning or ETag headers to detect staleness and request only when necessary.
Example (simple dedupe cache):
const cache = new Map(); // key -> { data, ts }
const inFlight = new Map(); // key -> promise
function fetchCached(key, fn, ttl = 5000) {
const entry = cache.get(key);
if (entry && Date.now() - entry.ts < ttl) return Promise.resolve(entry.data);
if (inFlight.has(key)) return inFlight.get(key);
const p = fn().then(data => { cache.set(key, {data, ts: Date.now()}); inFlight.delete(key); return data;});
inFlight.set(key, p);
return p;
}
4. API call inside useEffect is mid-flight when the component unmounts. What problems does this cause with React state updates? How do you cancel or abort the request safely?
Answer / Solution
- Problem: If the async call resolves after unmount, calling
setStatewill warn (or in older React, cause memory leaks). Avoid updating state on unmounted components. - Solution: Use
AbortControllerto cancel fetch requests in cleanup, and/or maintain anisMountedref to guardsetState. For fetch/axios (with cancellation) abort the request inuseEffectcleanup. For other async work, checkmountedRef.currentbefore state updates. - Always clean up intervals/timers and abort network requests to avoid memory leaks.
Example using AbortController:
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(r => r.json())
.then(data => setData(data))
.catch(err => {
if (err.name === 'AbortError') return; // ignore abort
setError(err);
});
return () => { controller.abort(); };
}, []);
5. You update UI optimistically, but the API fails with a 409 conflict. How do you roll back the UI, notify the user, and avoid corrupting local state?
Answer / Solution
- Before optimistic update, snapshot the previous state (shallow copy or structural clone depending on complexity). Apply optimistic change immediately.
- Make the API call. If success, confirm and possibly reconcile server response (merge server-authoritative fields). If API returns
409 Conflict(meaning server has a different version), roll back using the snapshot or fetch the canonical server state and merge, then show a clear user message explaining the conflict and offer options (retry, merge, open editor). - Avoid partial/inconsistent updates by disabling conflicting UI controls until conflict resolved; do not mutate deep shared objects in place — use immutability to make rollback simple.
- Log the conflict and optionally provide a small UI that highlights differences and allows user to choose which version to keep.
Example (pattern):
const prev = state.items;
setState(s => ({ ...s, items: optimisticItems }));
try {
await api.update(optimisticItems);
} catch (err) {
if (err.status === 409) {
setState(s => ({ ...s, items: prev })); // rollback
showToast('Update failed: data out of date. Please refresh or merge changes.');
// optionally fetch server state to present a diff
} else {
// handle other errors
}
}
6. You're building a live dashboard with updates every 5 seconds via polling. How do you prevent memory leaks, stale closures, and sync issues when switching tabs or navigating away?
Answer / Solution
- Use
useEffectto start polling and clean up on unmount. Keep the polling callback stable (use refs oruseCallback) to avoid stale closures capturing old state. - Pause polling when the tab is hidden (use
document.visibilityState+visibilitychange) to save resources and avoid race conditions. Resume when visible. - Clear intervals on unmount and abort any in-flight requests with
AbortController. Use a ref for latest state so callback reads up-to-date values without re-creating the interval. - Optionally use
requestAnimationFrameorsetTimeoutloop instead ofsetIntervalfor more control and to avoid overlapping calls (start next poll after previous completes). - For multi-tab consistency, use BroadcastChannel or server push (WebSocket / SSE) so only one tab polls and others receive updates, or use leader election.
Example (safe polling skeleton):
useEffect(() => {
let mounted = true;
const controller = new AbortController();
let timeoutId;
async function poll() {
if (!mounted || document.hidden) return scheduleNext();
try {
const res = await fetch('/api/live', { signal: controller.signal });
const data = await res.json();
if (mounted) setData(data);
} catch (err) { /* handle */ }
scheduleNext();
}
function scheduleNext() { timeoutId = setTimeout(poll, 5000); }
poll();
document.addEventListener('visibilitychange', () => {
if (!document.hidden) poll();
});
return () => {
mounted = false;
controller.abort();
clearTimeout(timeoutId);
document.removeEventListener('visibilitychange', () => {});
};
}, []);
7. User updates their profile from two tabs at the same time. How do you avoid race conditions or data overwrites? What techniques can help keep the UI consistent?
Answer / Solution
- Prefer server-side concurrency control: use optimistic concurrency with a
versionorETag. The client sendsIf-Match: <version>; server rejects if versions mismatch (409) and returns latest data. - Client-side: show conflict UI when 409 occurs, fetch server version, present a diff and let the user merge or overwrite explicitly.
- Use WebSockets/BroadcastChannel to notify other tabs of updates so they can refresh or prompt the user.
- Minimize editable surface: save small partial updates rather than big whole-resource PUTs to reduce conflict scope.
- For automatic merges, only merge non-conflicting fields; for conflicting fields require user resolution.
Example (versioning approach):
// GET /profile returns { data, version: "v123" }
// PUT /profile must include header If-Match: v123
try {
await fetch('/profile', { method: 'PUT', headers: { 'If-Match': version }, body: JSON.stringify(payload) });
} catch (err) {
if (err.status === 412 || err.status === 409) {
// fetch latest and prompt user to merge
}
}
8. Your app needs to display partial results from a large paginated API. How do you implement infinite scroll efficiently without over-fetching, flickering, or crashing?
Answer / Solution
- Use
IntersectionObserverto detect when the sentinel element is visible and then fetch the next page (instead of listening to scroll events). - Keep a pagination state:
page,isLoading,hasMore. Prevent duplicate fetches by checkingisLoadingbefore requesting next page. - Debounce/throttle requests and use a queue/concurrency limit for parallel requests.
- Merge incoming pages immutably into existing list to avoid flicker; keep placeholders for loading page rows. Cache pages (by page number or cursor) to avoid re-fetching on back-scroll.
- Use a stable key for list items to avoid remounting. Use virtualization (e.g.,
react-windoworreact-virtualized) for very large lists to avoid DOM overgrowth and crashes.
Example (basic infinite scroll):
const sentinelRef = useRef();
useEffect(() => {
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && !isLoading && hasMore) loadNextPage();
}, { rootMargin: '200px' });
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [isLoading, hasMore]);
9. Your API rate limit is exceeded due to too many calls in quick succession. How do you queue, throttle, or debounce API calls in React to stay within limits?
Answer / Solution
- Implement client-side rate limiting: throttle (allow N calls per time window) or debounce (coalesce rapid inputs into a single call). For many small events, debounce is best; for steady traffic, token-bucket or leaky-bucket algorithms work.
- Use a request queue with a worker that processes requests at a capped rate (e.g., 5 reqs/sec). Enqueue requests and resolve them as tokens become available. Retry failed requests with exponential backoff and respect
Retry-Afterheaders from the server. - Use a shared centralized request layer so all components respect the same rate limit. Consider server-side batching endpoints to reduce calls.
- For search/autocomplete, debounce input (e.g., 300ms) + cancel stale requests with
AbortController.
Example (simple debounce for search):
const [query, setQuery] = useState('');
useEffect(() => {
const id = setTimeout(() => { if (query) fetch(`/search?q=${query}`) }, 300);
return () => clearTimeout(id); // debounce
}, [query]);
10. Your React app works fine in dev, but breaks in prod due to CORS errors. How do you debug, prevent, and correctly configure cross-origin API requests from the frontend?
Answer / Solution
- Debug: Check browser console network tab to see the failing request and inspect response headers. If preflight fails, check response for
Access-Control-Allow-Origin,Access-Control-Allow-Methods, andAccess-Control-Allow-Headers. Usecurl -Ito inspect headers from the server directly. - Fix server: Configure the server to return
Access-Control-Allow-Origin: <your-origin>or*(careful with credentials). For credentialed requests (cookies / Authorization), setAccess-Control-Allow-Credentials: trueand specify the exact origin (not*). Allow the necessary headers and methods, and respond toOPTIONSpreflight requests properly. - Frontend considerations: If sending cookies, set
fetch(url, { credentials: 'include' })and ensure server allows credentials. Avoid sending custom headers unless necessary; if you must, ensure server accepts them inAccess-Control-Allow-Headers. - Alternative: Use a same-origin proxy (server-side) in production to avoid CORS entirely by routing API calls through your backend. Or configure CDN / reverse proxy (Nginx) to add the correct CORS headers.
- Security: Only allow trusted origins in production.
Example Nginx snippet to allow CORS:
location /api/ {
add_header 'Access-Control-Allow-Origin' 'https://your-app.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE';
add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type';
add_header 'Access-Control-Allow-Credentials' 'true';
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
}