Skip to content
Putting technology to work.
Insights to guide decisions and action.

Search articles

Graduating from raw fetch: building robust API clients for contract projects

Table of contents · 6 items

"Sometimes the screen freezes completely and stops responding without showing anything." In custom development, teams often receive vague bug reports like this. Investigating the issue reveals raw fetch calls to external APIs. Because no timeouts were configured, temporary latency spikes on the external service leave the application waiting indefinitely, causing the UI to freeze silently. In other cases, an external endpoint silently modifies its response format, returning undefined where data.items was expected, causing runtime crashes when array methods are invoked. While fetch is built into browsers and Node.js for easy use, it offers zero native safeguards for failure scenarios. Hardcoding bare fetch calls across different views scatters error handling logic, forcing developers to fix individual call sites one by one whenever failures occur.

To break this cycle, introduce an API client layer that centralizes timeouts, retries, error handling, and response validation. As reported by InfoQ in June 2026 covering Ky 2.0 Fetch API Wrapper (InfoQ), lightweight libraries that wrap fetch to provide practical safeguards by default serve as excellent blueprints for crafting your own client layer. In this article, we break down what responsibilities should be centralized to achieve failure-resistant HTTP communication in custom development.

Why hardcoding raw fetch calls causes incidents

Calling fetch directly within components works fine at first. Outages arise because real-world production environments invariably expose gaps that fetch does not handle out of the box.

First is the lack of timeouts. By default, raw fetch waits indefinitely if the remote server fails to respond. When an external integration stalls, users are left staring at a frozen screen with no indication of what went wrong.

Second is not throwing exceptions on HTTP errors. Even when a server returns a 404 or 500 status, fetch resolves successfully as long as network communication succeeded. Without checking response.ok every time, error responses are mistakenly processed as valid data. Hardcoded calls inevitably lead to incidents where this check was omitted.

Third is the absence of automatic retries for transient failures. Temporary failures caused by traffic spikes (such as timeouts or 503s) often resolve if retried after a brief pause. Raw fetch does not do this, forcing developers to either handcraft retry logic everywhere or give up on retrying altogether.

Fourth is unverified response shapes. Raw fetch trusts incoming JSON unconditionally. If an external service changes its payload structure, you won't detect the issue until some downstream component attempts to access undefined and crashes at runtime.

Expecting individual engineers to remember these nuances at every invocation is unrealistic. The true path to resilience is centralizing these controls so callers never have to worry about low-level transport details.

Four responsibilities to centralize

The API client layer should handle four core responsibilities that directly counteract the issues above. These should be encapsulated into a unified wrapper around fetch.

ResponsibilityRaw fetchCentralized client
TimeoutNone (waits indefinitely)Uniform default applied (e.g., 10s)
HTTP errorsNo exception thrownConverts 4xx/5xx to catchable exceptions
RetriesNoneRetries transient failures with exponential backoff
Response validationTrusts shape blindlyValidates against schema before returning

Lightweight wrappers like Ky include these four safeguards by default. Timeouts and retries are active out of the box, HTTP errors throw exceptions, and response schema validation can be hooked in easily. When building an internal client, your goal should be identical: implement timeouts via AbortController, throw exceptions based on response.ok, retry only transient errors, and validate shapes with schemas before returning—all wrapped inside a single function.

// 集約したAPIクライアントの骨子(自前実装の例)
import { z } from "zod";

async function apiClient<T>(
  url: string,
  schema: z.ZodType<T>,
  { retries = 2, timeoutMs = 10_000 }: { retries?: number; timeoutMs?: number } = {},
): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), timeoutMs); // タイムアウト
    try {
      const res = await fetch(url, { signal: ctrl.signal });
      if (!res.ok) {
        // 一時的なエラー(5xx)だけ再試行、4xxは即失敗
        if (res.status >= 500 && attempt < retries) {
          await wait(2 ** attempt * 300); // 指数バックオフ
          continue;
        }
        throw new ApiError(res.status, await res.text());
      }
      // 返す前に形を検証する。ここを通ればT型を保証できる
      return schema.parse(await res.json());
    } catch (err) {
      if (isTransient(err) && attempt < retries) {
        await wait(2 ** attempt * 300);
        continue;
      }
      throw err; // 再試行しても駄目なら呼び出し側へ
    } finally {
      clearTimeout(timer);
    }
  }
}

Call sites simply pass the URL and expected shape, as shown in apiClient("/api/orders", OrderListSchema). View components no longer need to worry about timeouts, retries, or validation. Because error policies live in one place, adjusting your strategy—such as extending timeouts or changing retry attempts—takes effect globally with a single edit.

Making response validation your type boundary

Among these four responsibilities, schema validation is especially crucial in custom development. TypeScript type annotations can be deceptive during compilation and offer zero runtime protection. Casting the return value of fetch with as OrderList merely feeds the compiler wishful thinking; if the external API returns an unexpected shape, that assumption falls apart at runtime deep in downstream code.

Passing responses through schema validation (such as schema.parse above) turns your API client into a security checkpoint verifying payloads at the system boundary. If a third-party endpoint alters its structure, the issue is flagged with an explicit error right at the ingress point rather than crashing a distant component. In custom development, stopping errors close to their source drastically slashes debugging time. The less reliable the external dependency, the more valuable ingress validation becomes. The hazard of blindly trusting external services mirrors the mindset discussed in our article on supply chain audits: never trust external inputs without verification.

This centralized client architecture is equally effective when your backend services communicate with upstream third parties. Pairing this approach with the layer separation explored in our article on building backends with Hono allows you to solidify networking resilience and organize routing in one concerted effort.

Pitfalls when adopting it in client web development

On an apparel e-commerce admin panel whose maintenance we took over (client name withheld), the aforementioned random UI freezes were an ongoing issue. Raw fetch calls to external inventory APIs were hardcoded in over twenty places, each using inconsistent timeouts and error handling. One view neglected to check response.ok, crashing whenever it attempted to parse HTML maintenance pages as JSON.

Instead of a full rewrite, we introduced a centralized API client layer and migrated endpoints incrementally as features were touched. We established a unified policy: a 10-second timeout, up to two retries with exponential backoff on 5xx errors, and schema validation on inventory responses, while removing raw fetch calls from UI code. We updated legacy hardcoded calls during regular maintenance, intentionally avoiding mass rewrites. All we did was centralize scattered error-handling logic. As a result, receiving HTML during maintenance windows triggered explicit errors at the network boundary, temporary network hiccups recovered automatically via retries, and vague reports of freezing stopped entirely.

The most valuable lesson from this engagement was that retries must be restricted exclusively to idempotent operations. While data fetches (GET) carry no side effects regardless of retry counts, naively retrying write operations like order submissions can trigger duplicate transactions. When enabling automatic retries in a centralized client, either exclude state-mutating requests or agree upon an idempotency key mechanism with the client's API before activating them. Indiscriminately retrying every request only introduces new failures.

Another trap is swallowing errors silently. If a centralized client raises an exception but callers use catch without rendering fallback UI, users are still left facing a silent failure. The client layer's responsibility ends at turning failures into explicit exceptions; communicating errors to users—via retry prompts or clear error messages—remains a separate responsibility for calling components.

Where to begin

If fetch is scattered across your codebase with divergent failure handling, establishing an API client layer is well worth the investment. You do not need to start with an exhaustive library evaluation. Encapsulating timeouts, HTTP error exceptions, targeted retries, and schema validation within a single function is all it takes to begin.

Start by identifying the single external API call causing the most incidents and route it through a centralized client. Migrate legacy calls gradually during ongoing maintenance to keep diffs and reviews manageable. Deciding between a lightweight library like Ky or a thin internal wrapper simply comes down to whether you want to avoid third-party dependencies or prefer comprehensive built-in defaults.

If your team is troubled by third-party APIs causing UI freezes, runtime crashes from payload drifts, or unmaintainable networking code, please contact GleamHub. We can audit your system's networking layer and help you design a phased migration toward resilient API clients.

Sources

Share this articleXFacebook
Kakeru Suzuki

Fascinated by the possibilities of technology, has had a deep interest in programming and digital art since student days

Turn this article's theme into your company's next step

Concrete steps forward for your organization.

We organize your desired architecture, legacy systems, and operational requirements to formulate your next steps toward execution.

  • Desired architecture
  • Integration with existing environments
  • Operational requirements
Consult on development & operations initiatives

You can consult with us from the initial conceptual stage. Details from this article will be carried over to the inquiry form.

Receive the latest articles by email