RizTech Academy logo
RizTech Academy
Connecting the Two HalvesLesson 3 of 425 min

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

  1. Build ApiError with isRetryable. Return a 500 and confirm it is retryable; return a 403 and confirm it is not.
  2. Return HTML from a failing endpoint and confirm the .catch(() => null) keeps the message useful.
  3. Add retries with backoff. Log each attempt and watch the delays grow.
  4. Remove the write guard, make a POST time out, and confirm you get duplicates.
  5. Add a timeout and point at an endpoint that never responds.
  6. Add error.tsx inside products/ only. Throw there and confirm the header survives.
  7. Render error.message and write down what could leak.
  8. Make a reviews section fail and confirm the product page still renders.
  9. Build the offline banner. Toggle offline in DevTools.
  10. 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