The Tool Desk
Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →Outbyte Driver Updater FREEFix the driver behind crashes, sound loss and screen glitchesFind Drivers →iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more
Reliable API error handling in a TypeScript frontend is a pipeline, not a single try/catch. Decode the server response at one contract boundary, normalize it into a stable ApiError, classify the failure in the data layer, and handle it at the smallest UI boundary that can recover. A field validation problem belongs beside the form; a failed list query needs an inline retry state; an expired session needs re-authentication; an unexpected render failure needs a React error boundary.
This separation explains why fetch does not throw for a 404, why a React Error Boundary should not be your API-request handler, and how to give users useful recovery actions without exposing transport-specific details throughout the component tree.
The error pipeline: contract, data layer and UI boundary
Design the flow as four explicit stages:
- Wire contract: the API returns a documented error shape, preferably
application/problem+jsonbased on RFC 7807. - Normalization: one request wrapper checks the HTTP response, content type, parsing result and abort/network conditions, then throws a typed error.
- Classification: the data layer distinguishes transport, HTTP, authentication, authorization, validation, domain and decoding failures.
- Presentation and recovery: the UI maps each class to a field message, retryable state, sign-in flow, navigation, or last-resort fallback.
Components should consume a discriminated union or query state such as idle, loading, success and error. They should not know whether the request used Fetch, Axios, a generated client or a particular server framework.
Do these 3 things before closing this tab:
1Clear out junk files and repair common Windows errors2Fix the driver behind crashes, sound loss and screen glitches3Repair Windows errors before they cause bigger problemsDefine a stable error contract
RFC 7807 defines a machine-readable problem-details object for HTTP APIs. Its standard members are type, title, status, detail and instance. The type URI is the primary identifier for the problem type. Treat detail as human-oriented text, not as a code that application logic parses. Servers may add extension members, and clients must ignore extensions they do not recognize.
Popular on Amazon
A practical envelope can add a stable application code, field-level errors and a correlation identifier while retaining the standard members:
{
"type": "https://api.example.com/problems/invalid-order",
"title": "Order cannot be submitted",
"status": 422,
"detail": "One or more items are no longer available.",
"instance": "/requests/01J8...",
"code": "ORDER_INVALID",
"fieldErrors": {
"items[0].quantity": "Only two units remain."
},
"requestId": "01J8..."
}
Keep codes and types stable across releases; presentation text can change or be localized. Do not make the client infer a validation error by searching for words in detail. If a response has no recognized problem document, the client should still produce a normalized error with the HTTP status and a safe generic message.
What the server should guarantee
- Use the correct
Content-Type, normallyapplication/problem+jsonfor problem details. - Return an HTTP status that reflects the class of failure.
- Provide a stable
typeor application code for machine decisions. - Include field keys in a documented format when returning validation errors.
- Include an occurrence or correlation identifier that support staff can search.
- Keep titles and details safe for the intended audience; never send stack traces or secrets to browsers.
Why fetch does not throw on a 404
The Fetch API fulfills its promise when the server returns an HTTP response, including 404, 401 or 500. It rejects for request failures such as a network error, an invalid URL scheme or another failure before a usable response exists. Response.ok is true only for status codes from 200 through 299.
Therefore, every wrapper must check response.ok (or the status) and deliberately parse the body. A bare call such as fetch(url).then(response => response.json()) can treat a 404 as a successful promise unless your code performs that check.
Also popular on Amazon
- Perfect for front-end developers, full-stack engineers, and software architects who build large-scale web applications and want to showcase their passion for strictly typed development and modern coding practices.
- Great gift for programmers, computer science students, and tech professionals who love TypeScript syntax, static type checking, and building robust web applications for birthdays, holidays, or developer meetups.
Normalize errors once in a request wrapper
Put transport handling in one module. The wrapper below illustrates the important decisions without tying the rest of the application to Fetch-specific objects.
type FieldErrors = Record<string, string>;
type ApiErrorKind =
| "http"
| "network"
| "abort"
| "decode"
| "validation"
| "authentication"
| "authorization"
| "domain";
class ApiError extends Error {
constructor(
public readonly kind: ApiErrorKind,
message: string,
public readonly details: {
status?: number;
code?: string;
type?: string;
title?: string;
fieldErrors?: FieldErrors;
instance?: string;
requestId?: string;
retryable: boolean;
cause?: unknown;
}
) {
super(message);
this.name = "ApiError";
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isProblem(value: unknown): value is Record<string, unknown> {
return isRecord(value) &&
(typeof value.type === "string" || typeof value.title === "string" ||
typeof value.status === "number" || typeof value.detail === "string");
}
function classifyStatus(status: number, body: Record<string, unknown>): ApiErrorKind {
if (status === 401) return "authentication";
if (status === 403) return "authorization";
if (status === 400 || status === 409 || status === 422) {
return body.code === "VALIDATION_FAILED" ? "validation" : "domain";
}
return "http";
}
async function request<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
let response: Response;
try {
response = await fetch(input, init);
} catch (cause) {
if (cause instanceof DOMException && cause.name === "AbortError") {
throw new ApiError("abort", "Request was cancelled", { retryable: false, cause });
}
throw new ApiError("network", "The network request failed", { retryable: true, cause });
}
const contentType = response.headers.get("content-type") ?? "";
let body: unknown = undefined;
if (contentType.includes("json")) {
try {
body = await response.json();
} catch (cause) {
if (!response.ok) {
throw new ApiError("decode", "The server returned an unreadable error", {
status: response.status, retryable: false, cause
});
}
throw new ApiError("decode", "The server returned invalid data", {
status: response.status, retryable: false, cause
});
}
} else if (!response.ok) {
throw new ApiError("http", "The server returned an unexpected response", {
status: response.status, retryable: response.status >= 500
});
}
if (!response.ok) {
const problem = isProblem(body) ? body : {};
const status = response.status;
const kind = classifyStatus(status, problem);
const retryable = status >= 500 || status === 408 || status === 429;
throw new ApiError(kind, String(problem.detail ?? problem.title ?? "Request failed"), {
status,
code: typeof problem.code === "string" ? problem.code : undefined,
type: typeof problem.type === "string" ? problem.type : undefined,
title: typeof problem.title === "string" ? problem.title : undefined,
fieldErrors: isRecord(problem.fieldErrors)
? Object.fromEntries(Object.entries(problem.fieldErrors).filter(([, v]) => typeof v === "string"))
: undefined,
instance: typeof problem.instance === "string" ? problem.instance : undefined,
requestId: typeof problem.requestId === "string" ? problem.requestId : undefined,
retryable
});
}
try {
return body as T;
} catch (cause) {
throw new ApiError("decode", "Response data could not be used", { retryable: false, cause });
}
}
In production, validate successful payloads at runtime rather than relying only on TypeScript, because TypeScript types disappear when JavaScript runs. A schema validator or carefully maintained type guard should reject a payload that does not have the fields the UI requires. That failure is a contract-decoding error, not a server validation message.
Handle empty and non-JSON responses deliberately
Successful 204 responses have no JSON body, and some gateways return HTML or plain text for an error. Check the content type before calling response.json(). Decide whether an endpoint permits an empty body and encode that in its return type, such as Promise<void> for a successful 204. A malformed error body should not erase the useful status code; report it as a decoding failure carrying the status.
Keep aborts separate from failures
Cancellation commonly occurs when a component unmounts, a user changes a search term, or a newer request supersedes an older one. Treat an abort as a non-error control path: do not show a red notification, do not retry it, and do not log it as an outage. Preserve the distinction in ApiError.kind so callers can ignore it explicitly.
Also popular on Amazon
Classify failures by recovery action
Status codes alone are not enough. A 409 can represent a business conflict that requires editing, while a 503 may be transient and retryable. Use the normalized kind, stable code or type, status and operation context together.
| Failure class | Typical evidence | Recovery | UI scope |
|---|---|---|---|
| Network/transport | No usable response; Fetch rejected | Check connectivity and retry with bounded backoff | Component or page query state |
| Abort | AbortError or cancellation signal |
Silently discard stale work | None |
| Authentication | HTTP 401 or an equivalent stable code | Refresh session if supported, otherwise sign in again | Session/application shell |
| Authorization | HTTP 403 | Explain that the account lacks permission; offer navigation | Page or feature |
| Validation | HTTP 400/422 with field errors | Put messages beside fields and preserve entered values | Form |
| Domain conflict | Stable problem type/code, often 409 | Explain the business rule and request a user decision | Form, dialog or page |
| HTTP/server | 5xx, 408 or 429 | Retry when safe; otherwise show status and support identifier | Query or route |
| Contract decoding | Unexpected content type or invalid JSON/data shape | Do not retry blindly; report a versioned client/server mismatch | Route or application fallback |
| Render/runtime | Exception while React renders | Use an Error Boundary and report the bug | Component, route or whole app |
Show field errors without crashing the page
Validation belongs next to the form that can correct it. Keep the normalized fieldErrors map in form state, associate each message with its input, and preserve values so the user does not have to re-enter them.
async function submit(values: OrderDraft) {
setSubmitting(true);
setErrors({});
try {
await request<Order>("/api/orders", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(values)
});
navigate("/orders");
} catch (error) {
if (error instanceof ApiError && error.kind === "validation") {
setErrors(error.details.fieldErrors ?? {});
return;
}
throw error;
} finally {
setSubmitting(false);
}
}
Render a summary for screen-reader users as well as messages beside individual controls. If a domain conflict affects the whole submission, show it at form level rather than attaching it to an arbitrary field. Never display raw stack traces, internal identifiers or unsanitized server text as HTML.
Free tools Windows power users keep installed
One-click scans. No signup required.
Handle failed queries with the smallest useful boundary
A failed list or detail query should normally leave the surrounding navigation and layout usable. Render an inline error state with a retry button, retain already loaded data when that is safe, and explain what will happen when the user retries.
Also popular on Amazon
function OrdersPanel() {
const query = useQuery({
queryKey: ["orders"],
queryFn: ({ signal }) => request<Order[]>("/api/orders", { signal }),
retry: (count, error) =>
error instanceof ApiError && error.details.retryable && count < 2
});
if (query.isPending) return <Spinner />;
if (query.isError) {
if (query.error instanceof ApiError && query.error.kind === "abort") return null;
return (
<InlineError
message="Orders could not be loaded."
requestId={query.error instanceof ApiError ? query.error.details.requestId : undefined}
onRetry={() => query.refetch()}
/>
);
}
return <OrderList orders={query.data} />;
}
TanStack Query provides useQueryErrorResetBoundary to reset query errors in the closest reset boundary, or globally when no closer boundary exists. Use that reset mechanism when an error boundary renders a retry action so the next render can attempt the query again instead of immediately replaying the same error.
Retry only failures that can improve
Retry idempotent, transient operations with a bounded count and backoff. Network failures, 408, 429 and selected 5xx responses are common candidates, subject to the endpoint’s semantics and any server-provided rate-limit guidance. Do not automatically retry validation, authorization, authentication, malformed-response or deterministic domain errors. Be especially careful with POST requests: retry only when the API supports idempotency keys or otherwise guarantees that repeating the operation is safe.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Authentication and authorization need session-aware recovery
A 401 generally means the current credentials are missing, expired or invalid. A centralized client can attempt one controlled token refresh, queue concurrent requests behind that refresh, and redirect to sign-in if it fails. Avoid infinite refresh loops: mark the original request, and do not refresh in response to the refresh endpoint itself.
Recommended Free Tools
A 403 is different. The user may be signed in but lacks permission. Do not send them through a sign-in loop. Explain the restriction, offer an appropriate route back, and keep any request or instance identifier for support diagnostics.
Also popular on Amazon
Use React Error Boundaries for render failures, not ordinary API errors
React documents that a JSX try/catch cannot catch a child render failure. Render-time exceptions belong in an Error Boundary. A boundary can replace a broken subtree with a fallback and report the exception, preventing one component from blanking the entire application.
Keep request errors in query or component state when the UI has a meaningful recovery action. You may deliberately rethrow an unrecoverable contract or application error during render so an Error Boundary can handle it, but do not use that as the default path for a 422 or a temporary network outage.
Route-level failures
React Router route modules catch loader, action, component and other route-module failures and render the nearest route ErrorBoundary. This is the right scope when a route cannot produce its page, while shared navigation and unrelated routes can remain available. Route boundaries are separate from form-validation handling and from telemetry concerns: a validation response should normally stay in the form, not become a route crash.
PC Slower Than It Used to Be?
A free scan shows the junk files, broken settings and background clutter dragging Windows down - then fixes them in one click.Free scan · Windows 10 & 11Outdated Drivers Are Slowing You Down
One free scan finds every outdated or missing driver and matches the right update for your exact hardware.Free scan · exact hardware matchDesign retries, fallbacks and navigation as explicit decisions
For every error class, document five axes during a design review:
- Failure source: network, HTTP status, contract decoding, domain rule or render/runtime code.
- Recovery action: retry, refresh, re-authenticate, edit input, navigate or report a bug.
- Scope: field, component, route or whole application.
- Contract stability: stable machine type/code versus presentation-only text.
- Observability: request or instance ID and enough context to reproduce the event.
This prevents the common anti-pattern of sending every exception to one global toast. A toast cannot focus an invalid field, explain missing permissions or recover a failed route by itself.
Log safe, useful diagnostics
Log the normalized error kind, endpoint or operation name, HTTP status, request or instance identifier, client release version and a redacted account or session context. Redact access tokens, cookies, personal data and server stack traces before sending telemetry. Include whether the request was retried and whether it was cancelled, because those facts change the meaning of the event.
Show users a short, actionable message. Give support staff the request or instance identifier rather than exposing internal stack information. Correlation IDs are especially valuable when a user reports that a retry, refresh or sign-in did not resolve the problem.
Quick Recap
Implementation checklist
- Document one problem-details envelope and stable type or code values.
- Build one request wrapper that checks
response.ok, content type, parsing and abort/network exceptions. - Validate successful payloads at runtime before exposing them as typed data.
- Throw one normalized
ApiErrorshape carrying kind, status, code or type, field errors, instance and retry metadata. - Keep Fetch, Axios and other transport objects out of React components.
- Map validation errors to fields, query failures to local retry states, 401 to session recovery and 403 to permission messaging.
- Use route Error Boundaries for route-module failures and React Error Boundaries for unexpected render exceptions.
- Retry only safe, transient operations with bounded backoff; never blindly retry deterministic failures.
- Reset query error state before retrying through a boundary.
- Capture redacted diagnostics with a correlation identifier and client release.
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

