How to Handle API Timeout and Retry Scenarios in RealWorld Clients: 7 Resilience Strategies

Use AbortController to enforce a 10-second request ceiling, wrap fetches in limited exponential-backoff retry loops that only target 5xx responses and network errors, and surface clear "Network error – please try again" messages when all retries exhaust.

The gothinkster/realworld repository encodes battle-tested patterns for building resilient front-end applications that remain responsive during backend instability. By examining the specs/e2e/helpers/api.ts test harness and the official error-handling specifications, you can implement robust timeout and retry logic that prevents indefinite hangs and duplicate side effects.

Detecting Timeouts with AbortController

The RealWorld test suite demonstrates how to guarantee the UI never waits indefinitely for a stalled backend. In specs/e2e/helpers/api.ts, the harness creates an AbortController instance for every fetch, pairing it with setTimeout to cancel the request after a configurable deadline.

You should centralize the timeout value—commonly DEFAULT_TIMEOUT_MS = 10_000—in a single constants module, mirroring the global configuration found in specs/e2e/playwright.base.ts (line 32). This ensures every API call shares the same 10-second ceiling without scattering magic numbers throughout your codebase.

// Constants centralized as shown in the Playwright base config pattern
export const DEFAULT_TIMEOUT_MS = 10_000;
export const MAX_RETRIES = 3;
export const BASE_BACKOFF_MS = 250;

Implementing Intelligent Retry Logic

The backend error-handling specification (apps/documentation/src/content/docs/specifications/backend/error-handling.md) mandates that clients retry only transient failures—specifically HTTP 5xx responses and network-level AbortError exceptions—while treating 4xx client errors as permanent failures requiring user intervention.

This distinction prevents endless retry loops against broken requests. The specs/e2e/helpers/api.ts helper implements a capped retry loop that inspects the error type and status code before deciding whether to attempt another request.

Exponential Backoff and Jitter

To avoid thundering-herd effects on a recovering server, the specification recommends exponential backoff with jitter. After each failed attempt, wait baseDelay * 2^n + randomJitter milliseconds before the next retry, where n is the attempt count.

This calculation spreads retry traffic over time and reduces load spikes. The test harness logs each retry attempt, allowing you to tune the BASE_BACKOFF_MS (typically 250 ms) based on observed latency patterns.

Idempotent Request Selection

Apply retries only to idempotent HTTP verbs—GET, HEAD, and OPTIONS—or to POST/PUT requests that include unique idempotency keys. The error-handling documentation explicitly warns against blind retries of non-idempotent operations to prevent duplicate side effects such as double-posting comments or processing payments twice.

Building a Resilient Fetch Wrapper

Consolidate these strategies into a reusable TypeScript module that your components import. This wrapper combines AbortController timeouts, transient-error detection, and exponential backoff into a single async function.

// src/client/apiClient.ts
import { DEFAULT_TIMEOUT_MS, MAX_RETRIES, BASE_BACKOFF_MS } from './constants';

/**
 * Performs a fetch with timeout and exponential‑backoff retries.
 *
 * @param input   Request URL or Request object
 * @param init    fetch init options (method, headers, body …)
 * @returns       JSON‑parsed response body
 */
export async function apiFetch<T>(
  input: RequestInfo,
  init?: RequestInit
): Promise<T> {
  for (let attempt = 0; attempt <= MAX_RETRIES; ++attempt) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);

    try {
      const response = await fetch(input, {
        ...init,
        signal: controller.signal,
      });

      clearTimeout(timeoutId);

      // Only retry on network errors or server‑side failures
      if (!response.ok && response.status >= 500) throw new Error('5xx');
      if (!response.ok) throw new Error(`HTTP ${response.status}`);

      return (await response.json()) as T;
    } catch (err: any) {
      clearTimeout(timeoutId);

      // AbortError signals a timeout – treat it as transient
      const isTransient = err.name === 'AbortError' || err.message === '5xx';

      if (!isTransient || attempt === MAX_RETRIES) {
        // No more retries – bubble the error up to the UI layer
        throw err;
      }

      // Exponential back‑off with jitter
      const backoff = BASE_BACKOFF_MS * 2 ** attempt;
      const jitter = Math.random() * 100;
      await new Promise((r) => setTimeout(r, backoff + jitter));
    }
  }

  // Should never reach here
  throw new Error('Unexpected exit from retry loop');
}

The wrapper checks err.name === 'AbortError' to detect timeouts and response.status >= 500 to identify server-side hiccups, ensuring 4xx errors propagate immediately without wasting retry budget.

Surfacing Errors in the UI

When the retry limit exhausts, the UI must display a clear message and offer a manual retry path. The frontend tests specification (apps/documentation/src/content/docs/specifications/frontend/tests.md) requires implementations to show "Network error – please try again" rather than silent failures.

// src/pages/ArticlePage.tsx (React example)
import { apiFetch } from '../client/apiClient';
import { useEffect, useState } from 'react';

export function ArticlePage({ slug }: { slug: string }) {
  const [article, setArticle] = useState<Article | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    apiFetch<Article>(`/api/articles/${slug}`)
      .then(setArticle)
      .catch((e) => setError(e.message));
  }, [slug]);

  if (error)
    return (
      <div className="error">
        Network error – {error}.{' '}
        <button onClick={() => window.location.reload()}>Retry</button>
      </div>
    );
  if (!article) return <div>Loading…</div>;

  return (
    <article>
      <h1>{article.title}</h1>
      {/* … */}
    </article>
  );
}

This pattern keeps users informed and in control, matching the behavior expected by the RealWorld e2e test suite.

Instrumentation and Logging

The specs/e2e/helpers/api.ts test harness logs every retry attempt, timeout, and final outcome to the console. Production clients should replicate this instrumentation, sending telemetry to your logging service so you can identify flaky endpoints and adjust MAX_RETRIES or BASE_BACKOFF_MS based on real-world latency distributions.

Summary

  • Enforce hard timeouts using AbortController with a centralized 10-second default, as configured in specs/e2e/playwright.base.ts.
  • Retry selectively on transient 5xx responses and AbortError network failures only, per the backend error-handling specification.
  • Apply exponential backoff with jitter (baseDelay * 2^n + randomMs) to prevent overwhelming recovering servers.
  • Restrict retries to idempotent requests (GET, HEAD, OPTIONS) or uniquely keyed mutations to avoid duplicate side effects.
  • Surface clear UI feedback when retries exhaust, offering manual retry controls as documented in the frontend tests specification.
  • Instrument every attempt to tune resilience parameters based on observed backend behavior.

Frequently Asked Questions

How does the RealWorld specification recommend detecting request timeouts?

According to the specs/e2e/helpers/api.ts test harness and backend error-handling documentation, clients should instantiate an AbortController for each request and use setTimeout to trigger controller.abort() after a configurable deadline—typically 10 seconds. This converts hung connections into catchable AbortError exceptions that the retry logic can treat as transient failures.

Which HTTP status codes should trigger a retry in RealWorld clients?

The backend error-handling specification (apps/documentation/src/content/docs/specifications/backend/error-handling.md) dictates that retries should only occur on HTTP 5xx server errors and network-level failures such as AbortError. HTTP 4xx client errors indicate permanent request issues (e.g., validation failures) and must not trigger automatic retries.

What backoff strategy does RealWorld recommend for API retries?

The specification recommends exponential backoff with jitter, calculated as BASE_BACKOFF_MS * 2^attempt + randomJitter. This formula—demonstrated in the e2e test helpers—distributes retry traffic over time to avoid thundering-herd effects and gives the backend breathing room to recover from temporary load spikes.

How can RealWorld clients safely retry POST requests without causing duplicate side effects?

The error-handling documentation advises applying retries only to idempotent HTTP verbs (GET, HEAD, OPTIONS) unless the POST or PUT request includes a unique idempotency key (such as a client-generated request ID) that allows the server to recognize and deduplicate repeated submissions. Without this key, retrying a non-idempotent request risks double-posting comments or processing duplicate transactions.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →