Handling failure without showing a blank screen
Things will fail. The API will be down, the connection will drop mid-checkout, a deploy will be halfway through. The difference between an application people trust and one they abandon is almost entirely in what happens then.
The ways it fails
Each needs a different response, and treating them all the same is the mistake:
| Failure | Looks like | Response |
|---|---|---|
| Network down | fetch rejects |
"Check your connection", retry |
| API down | 502, 503, timeout | "We are having trouble", retry |
| Not found | 404 | notFound() |
| Not authenticated | 401 | refresh, then redirect to login |
| Not permitted | 403 | explain, do not retry |
| Validation | 400 | show errors against fields |
| Conflict | 409 | explain and offer an alternative |
| Server bug | 500 | apologise, log, retry once |
Only some of these are worth retrying. Retrying a 403 or a 400 will never succeed and only wastes the user's time.
A typed error
// apps/web/src/lib/api-error.ts
import type { ErrorCode } from "@kirana/shared";
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: ErrorCode | "NETWORK_ERROR",
message: string,
public readonly details?: Record<string, unknown>,
) {
super(message);
this.name = "ApiError";
}
static async from(response: Response): Promise<ApiError> {
const body = await response.json().catch(() => null);
return new ApiError(
response.status,
body?.code ?? "INTERNAL_ERROR",
body?.message ?? "Something went wrong.",
body?.details,
);
}
get isRetryable() {
return this.status >= 500 || this.code === "NETWORK_ERROR";
}
}
.catch(() => null) on parsing the body. A 502 from a proxy returns HTML,
not JSON, and response.json() would throw — replacing a useful "the API is
down" with a confusing parse error. This small line prevents a real class of
confusion.
isRetryable puts the decision in one place rather than in every call site.
Retrying, carefully
export async function request<T>(
path: string,
init: RequestInit = {},
attempt = 1,
): Promise<T> {
const MAX_ATTEMPTS = 3;
try {
const response = await fetch(`${BASE}${path}`, {
...init,
credentials: "include",
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw await ApiError.from(response);
return response.status === 204 ? (undefined as T) : await response.json();
} catch (error) {
const apiError =
error instanceof ApiError
? error
: new ApiError(0, "NETWORK_ERROR", "Could not reach the server.");
const isWrite = init.method && init.method !== "GET";
if (apiError.isRetryable && attempt < MAX_ATTEMPTS && !isWrite) {
await new Promise((r) => setTimeout(r, 2 ** attempt * 200));
return request<T>(path, init, attempt + 1);
}
throw apiError;
}
}
Four decisions worth stating.
A timeout. fetch waits indefinitely by default, so a hung server means a
spinner that never stops. AbortSignal.timeout bounds it.
Exponential backoff — 400ms, 800ms — so a struggling server is not hammered by every client retrying in lockstep.
Do not retry writes. A POST that timed out may have succeeded; retrying
creates a second order. Retry only GET unless the endpoint is explicitly
idempotent. This is the single most important line in the function.
A cap. Three attempts, then give up and tell the user.
Error boundaries
// apps/web/src/app/(shop)/products/error.tsx
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<EmptyState
title="We could not load the products"
description="This is usually temporary. Please try again."
action={
<div className="flex justify-center gap-3">
<Button onClick={reset}>Try again</Button>
<Button variant="secondary" onClick={() => location.assign("/")}>
Go home
</Button>
</div>
}
/>
);
}
Never render error.message. It can contain a database error or an internal
URL — module 3's warning.
Scope boundaries narrowly. An error.tsx at the root turns any failure into
a whole-page error. One inside products/ means the header, navigation and
footer survive while only the grid fails — which is far less alarming and lets
the user go elsewhere.
Degrade rather than collapse
The reviews failing should not take down the product page:
async function ProductReviews({ slug }: { slug: string }) {
try {
const reviews = await api.reviews.list(slug);
return <ReviewList reviews={reviews} />;
} catch {
return (
<p className="text-sm text-gray-500">
Reviews are unavailable at the moment.
</p>
);
}
}
Decide what is essential and what is decoration. A product page needs the product; it does not need reviews, recommendations or a stock countdown. Letting a secondary section fail quietly is better than a 500 for the whole page.
The rule: catch around the optional, let the essential propagate to the error boundary.
Offline
"use client";
export function OfflineBanner() {
const [offline, setOffline] = useState(false);
useEffect(() => {
const goOffline = () => setOffline(true);
const goOnline = () => setOffline(false);
setOffline(!navigator.onLine);
window.addEventListener("offline", goOffline);
window.addEventListener("online", goOnline);
return () => {
window.removeEventListener("offline", goOffline);
window.removeEventListener("online", goOnline);
};
}, []);
if (!offline) return null;
return (
<div role="status" className="bg-amber-100 px-4 py-2 text-center text-sm text-amber-900">
You are offline. Some things may not work.
</div>
);
}
A genuine useEffect: browser events, with cleanup.
navigator.onLine is read inside the effect rather than in useState, because
it does not exist on the server — the hydration rule from module 3.
Worth being honest about its limit: navigator.onLine reports whether the
device has a connection, not whether your API is reachable. Connected to a
café wifi with no internet, it says online. Treat it as a hint.
Failures worth being deliberate about
A slow connection is not a failure, and it is the common case for your customers. Test on Slow 3G with throttling, not only on your laptop.
A partial page is better than no page. Server components let the shell render while a section fails.
Never lose a user's input. A form that clears itself when submission fails is the most infuriating failure there is. Keep the state; show the error above the form.
Say what to do next. "Could not place your order — your cart is saved, please try again" reassures. "Error" does not.
Check your work
Which failures are worth retrying: 5xx and network errors. Never 4xx, which will not succeed on a retry.
Why .catch(() => null) when parsing an error body: a proxy's 502 returns
HTML, and parsing would throw a confusing error over a clear one.
Why not retry writes: a timed-out POST may have succeeded, so retrying can create a duplicate order.
Why a fetch timeout: without one a hung server produces a spinner that never stops.
Why scope error boundaries narrowly: a root boundary turns any failure into a whole-page error, losing the navigation.
What to catch around and what to let propagate: catch around optional sections; let essential failures reach the boundary.
Why read navigator.onLine in an effect: it does not exist on the server.
What it cannot tell you: whether your API is reachable — only whether the device has a connection.
Practice
- Build
ApiErrorwithisRetryable. Return a 500 and confirm it is retryable; return a 403 and confirm it is not. - Return HTML from a failing endpoint and confirm the
.catch(() => null)keeps the message useful. - Add retries with backoff. Log each attempt and watch the delays grow.
- Remove the write guard, make a POST time out, and confirm you get duplicates.
- Add a timeout and point at an endpoint that never responds.
- Add
error.tsxinsideproducts/only. Throw there and confirm the header survives. - Render
error.messageand write down what could leak. - Make a reviews section fail and confirm the product page still renders.
- Build the offline banner. Toggle offline in DevTools.
- Fail a form submission and confirm the user's input is still there.
Next: CORS, cookies and the security basics of the seam.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship