Without notes, state yesterday’s main idea and one unresolved question.
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
GETis safe. APOSTthat 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.
- Enumerate first. Write the four states in a comment before touching the render code.
- Loading. Set a
loadingflag true before the fetch and false in afinallyblock, so it clears on both success and failure. - Empty. After a successful fetch, if the array length is zero, render "No records yet" and the create button.
- 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
- Enumerate. List every primary screen. For each, write its four states in one line each. Include forms: their failure state is a rejected submit.
- Rank. Mark which are currently missing. Do the highest-traffic screen first.
- Implement loading, empty, and error for at least your three main screens. Every error state gets a next action.
- Add timeouts to your fetch calls with
AbortSignal.timeout, and add theresponse.okcheck everywhere it is missing. - Map server errors: add the error-handling middleware with a reference id, and confirm no response body contains a stack trace.
- 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.
- 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
fetchthrows on a 500. It does not. Checkresponse.okon every call. - A spinner with no exit. If the request fails and
loadingis never set back to false, the screen spins forever. Clear it infinally. - 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.
- This lesson claimed the
fetchpromise 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. - 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
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
Enumerate failure states, implement the highest-impact ones, and test each manually.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
No primary screen fails as a blank or unexplained state.
Working with AI today
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
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.