0 / 91
Week 13 · Day 86 of 91

Add resilient errors and empty states

Capstone Completion and Professional Handoff

Objective

Design behavior for failure, no data, timeouts, and invalid input.

The final phase is commissioning and handoff: validate under expected conditions, document limitations, and leave the next engineer a maintainable system.

  • user-visible recovery
  • server error mapping
  • retry limits

Why this matters

Yesterday you shipped three workflows that work when everything goes right. Today you handle everything else: the request that fails, the list that is empty, the server that takes twenty seconds, the form filled in wrong. A beginner app has one path. A professional app has four, and the user is never left staring at a blank rectangle wondering whether it is broken or still thinking.

The target for the hour: no primary screen in your app fails as a blank or unexplained state.

The four states every screen has

Any screen that fetches data has exactly four states. Name them, then build them.

State Meaning What the user sees
Loading The request is in flight Something moving, or "Loading records…"
Empty Request succeeded, zero results "No records yet" plus the button that creates one
Error Request failed What failed, and a safe next action
Success Data arrived The data

Most beginner screens implement only Success, so the other three all render as the same thing: nothing. Empty and Error look identical to the user, which is the worst possible outcome — one means "add your first record", the other means "your data is fine, we could not reach it". A user who cannot tell those apart may re-enter data they already have.

Instrument reading zero

A meter reading 0.00 V and a meter with a broken lead both display something calm and numeric. The good instrument distinguishes them: 0.00 versus OL or a blank flashing display. Your empty state is the honest zero; your error state is OL. Rendering both as a bare screen is shipping an instrument that cannot tell "no signal" from "not connected".

User-visible recovery

A useful error state explains what failed and offers a safe next action. Both halves matter. "Error" alone tells the user nothing. A stack trace tells them too much and helps an attacker. The shape that works:

Couldn't load maintenance records. The server didn't respond. Your saved records are safe. [Try again]

Four things there: what was being done, what happened, what is not damaged, and a button. A dead end is any failure state without that button — the user's only option is reloading the whole app or leaving.

One more rule: never destroy the user's input on failure. If a form submit fails, the typed values stay on screen. Clearing the form because the request failed makes the user pay for your outage.

Server error mapping

On the server, things fail as thrown exceptions with technical messages. Error mapping is turning those into a deliberate HTTP response: a status code that classifies the failure and a message safe to show a stranger.

You have used status codes since Week 6; today they become a policy:

Situation Status Message to the client
Body failed validation 400 Which field, and what was expected
Not logged in 401 "Sign in to continue"
Logged in, not allowed 403 "You do not have access to this record"
Id does not exist 404 "Record not found"
Anything unexpected 500 A generic message plus a reference id
app.use((err, req, res, next) => {
  const id = crypto.randomUUID();
  console.error({ id, path: req.path, message: err.message, stack: err.stack });
  res.status(500).json({ error: "Something went wrong.", reference: id });
});

The reference id is the trick worth stealing: the full detail goes to your Day 54 logs, the user gets a short code, and when they report it you can find the exact request. The user learns nothing about your database and you lose nothing diagnostically.

Swallowed errors are worse than crashes

try { ... } catch (e) {} — an empty catch — makes a failure invisible. The screen renders as if all is well, the data is missing, and nothing is logged. A crash tells you where it broke; a swallowed error costs hours. Every catch block must do at least one of: log it, show it, or rethrow it.

Retry limits

Some failures are transient — a dropped connection, a brief server restart. Retrying helps. But an unbounded retry loop turns one user's bad WiFi into a flood of traffic at exactly the moment your server is least able to take it.

The rules:

  • Bound the attempts. Two or three, then stop and show the error state.
  • Wait longer each time (back off): 1s, then 2s, then 4s. Immediate retries all fail together.
  • Only retry what is safe to repeat. A GET is safe. A POST that creates a record is not — retrying may create two. Prefer a user-triggered "Try again" button for writes; the human is the retry limit.
  • Never retry a 400 or a 403. The request was wrong or forbidden. Sending it again changes nothing.

Two minutes, right now

Start your frontend, then stop your API server. Click into a screen that loads data. Note exactly what you see — most likely a permanent empty box. That is the bug you are fixing today.

Timeouts

A request with no timeout can hang effectively forever, and the user sees a spinner that never resolves. Set one:

try {
  const response = await fetch("/api/records", { signal: AbortSignal.timeout(8000) });
  if (!response.ok) throw new Error(`Server responded ${response.status}`);
  const data = await response.json();
} catch (error) {
  if (error.name === "TimeoutError") {
    // took longer than 8 seconds — show the error state with a retry
  }
}

AbortSignal.timeout(8000) cancels the request after 8 seconds and rejects with an error whose name is "TimeoutError". And note line two: fetch does not throw on 404 or 500. It only rejects when the request could not be made at all. A 500 arrives as a perfectly fine response object with ok === false. Forgetting that check is the single most common data-fetching bug in beginner code — the app treats an error page as data and renders garbage.

Walkthrough

Take one screen — the list screen from workflow 1 — and give it all four states.

  1. Enumerate first. Write the four states in a comment before touching the render code.
  2. Loading. Set a loading flag true before the fetch and false in a finally block, so it clears on both success and failure.
  3. Empty. After a successful fetch, if the array length is zero, render "No records yet" and the create button.
  4. Error. Store the caught error in state and render the message plus a "Try again" button that re-runs the fetch.

Now force each state and watch it:

# In the terminal running your API, press Ctrl+C to stop it.
curl -i http://localhost:3000/api/records
curl: (7) Failed to connect to localhost port 3000 after 3 ms: Couldn't connect to server

Reload the screen: you should now see your error state, not a blank. Restart the API, delete every row from the table in a scratch database to see the empty state, and use DevTools → Network → throttling to make the loading state last long enough to read.

Checkpoint

For your list screen you can trigger all four states on demand and describe, for each, what the user is told and what they can do next.

Your turn

  1. Enumerate. List every primary screen. For each, write its four states in one line each. Include forms: their failure state is a rejected submit.
  2. Rank. Mark which are currently missing. Do the highest-traffic screen first.
  3. Implement loading, empty, and error for at least your three main screens. Every error state gets a next action.
  4. Add timeouts to your fetch calls with AbortSignal.timeout, and add the response.ok check everywhere it is missing.
  5. Map server errors: add the error-handling middleware with a reference id, and confirm no response body contains a stack trace.
  6. Test each state manually and record how you forced it: stop the API, empty the table, throttle the network, submit an invalid form, request an id that does not exist.
  7. Check inputs stay put: submit a form while the API is stopped and confirm your typing is still on screen.

Reviewer mode — after your states are in

Today's mode is reviewer: you bring finished work and ask for defects. A useful review produces specific, actionable findings with evidence from your code — not praise, not a rewrite.

"Review this app only for failure behavior. Find dead ends, swallowed errors, and actions users cannot recover from."

Demand a file and line for each finding, then judge it yourself before changing anything.

Common pitfalls

  • Empty and error rendering the same thing. They mean opposite things. Separate them.
  • Assuming fetch throws on a 500. It does not. Check response.ok on every call.
  • A spinner with no exit. If the request fails and loading is never set back to false, the screen spins forever. Clear it in finally.
  • Leaking internals to the user. Database messages and stack traces in the UI are a security problem as well as a usability one. Log the detail, show the reference id.

Verify it yourself

Open today's reference, MDN's Fetching data from the server, and find where it explains what happens when a request returns an error status.

  1. This lesson claimed the fetch promise does not reject on a 404 or 500. Find the sentence in MDN that confirms or contradicts it, and note which property you must check instead.
  2. Find how MDN suggests handling a failed request in the UI. Compare it to your error state — is yours more or less useful, and why?

Write both answers in your notes with the quoted line from MDN.

The hour

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Enumerate failure states, implement the highest-impact ones, and test each manually.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

No primary screen fails as a blank or unexplained state.

Working with AI today

AI as skeptical reviewer

Provide existing work and ask for concrete defects, risks, missing tests, and unsupported assumptions—not praise.

Review this app only for failure behavior. Find dead ends, swallowed errors, and actions users cannot recover from.

References

End-of-day quiz

Q1 What is a useful error state?
Q2 Which result best proves today’s work is complete?
Q3 What should an AI code review primarily produce?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.