Topic 16.4
Loading, Empty, Partial and Error States
In one line
A box can be loading, empty, broken or showing old numbers, and a blank rectangle says none of that. Give every box every state, let each box fail alone, retry with backoff, and mark old data as old.
Think of it like this
A shop door. A closed shop with a sign ("Back at 2. The shop next door is open") is fine: you know what to do. A shop with dark windows and no sign could be closed for lunch, closed for ever, or broken into. A blank chart is the dark window.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- skeleton
- Grey placeholder shapes shown where content will appear, so the layout does not jump.
- empty state
- What a box shows when the answer is "no rows": a reason and a way out.
- partial failure
- Some parts of the page failed and others worked. Each box shows its own state.
- backoff
- Waiting longer after each failed try: 500 ms, 1 s, 2 s.
- stale
- Old data that is still on screen. It must be labelled with its age.
- allSettled
Promise.allSettledwaits for every promise and reports each result, success or failure.
Step by step
01Loading: a skeleton and a sentence
The stage shows showState(box, "loading", …): three grey bars, which are decoration, and the sentence Loading rides…, which is what a screen reader reads. The bars pulse unless the visitor asked for reduced motion.
02Empty: a reason and a way out
Same box, no rows. It says why, and what to do. The role is still status: a screen reader announces the change.
03Error: what happened and a Try again button
The error box uses role="alert", which is announced at once, and the error colours from the theme. The button calls the same loader again and is disabled while it works, so one click is one retry.
04Stale: old numbers, labelled
The refresh failed, but the bars from the last good load are still there. They are dimmed a little and a badge says Showing old numbers. Last update 3 minutes ago.
Try it yourself
- 1
Change the wrong path
In the first example, change
stations-2026.csvtostations.csv. The Stations box loads:stations: loaded 25 rows, and only the partner feed stays broken. - 2
Allow more tries
Give
withRetrythe optiontries: 5inload. The flaky box is unchanged (it recovers on the third try) but the dead network now logs four failed attempts, waiting 500, 1000, 2000 and 4000 ms, before it gives up.
Code & diagrams
One wrong path and one dead network blank two boxes only. The 404 is tried once, the flaky call is tried three times (it works on the third), and the dead network is tried three times and then given up.
Preview
Console
Nothing logged yet.Expected console output
rides: loaded 365 rows
stations: Could not load stations-2026.csv (the server answered 404).
weather: attempt 1 failed, waiting 500 ms
weather: attempt 2 failed, waiting 1000 ms
weather: loaded 1 rows
late returns: empty
partner feed: attempt 1 failed, waiting 500 ms
partner feed: attempt 2 failed, waiting 1000 ms
partner feed: Failed to fetch
boxes on the page: 5 | error boxes: 2
stations: Could not load stations-2026.csv (the server answered 404).
error boxes after Try again: 2The preview has no network except this site's data, so a request to another address fails the way a dropped connection does. A server error has a status; a dead network has only a TypeError.
Preview
Console
Nothing logged yet.Expected console output
first load -> loading (skeleton)
first load failed -> error (message and Try again)
loaded -> ok
refreshing -> refreshing (rows stay, small hint)
no rows -> empty (a reason and a way out)
refresh failed -> stale (old rows and a badge: 3 minutes ago)
feed went quiet -> stale (old rows and a badge: 2 hours ago)
---
0 s -> just now
3 s -> just now
42 s -> 42 seconds ago
60 s -> 1 minute ago
185 s -> 3 minutes ago
7300 s -> 2 hours ago
90000 s -> 1 day ago
NaN s -> at an unknown time
-5 s -> at an unknown time
network failure: TypeError: Failed to fetchBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Load everything with Promise.all
In load, replace the whole catch body with throw problem;, so errors go to the caller. Change each await load( to jobs.push(load( and its closing ); to ));, write const jobs = []; above the five calls, and add await Promise.all(jobs); after them.
Myth vs fact
Myth
If a request fails, show an error box and clear the old data.
Fact
Old data with a clear "old" label is more useful than nothing. Replace it only when it is too old to be safe.
Myth
A spinner is the same as a skeleton.
Fact
A spinner says "wait" and leaves a gap that jumps. A skeleton holds the layout and previews the shape.
Myth
Retry until it works.
Fact
Endless retries hammer a struggling server and hide a real problem. Back off, give up after a few tries, and let the person retry.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Retry only what is safe to repeat. A
GETis; aPOSTthat creates something may create it twice unless the server takes an idempotency key. - ▸
Add jitter to the backoff (
delay * (0.5 + Math.random() / 2)): when a server restarts, a thousand wall screens retrying on the same schedule hit it together. - ▸
Announce changes politely. A loading sentence is
role="status"(polite) and an error isrole="alert"(assertive). Do not re-set the same text every second, or the screen reader interrupts itself. - ▸
Log the full error (status, URL, attempt) to the console or to your error tracker, but show the reader the short sentence. Show nothing the reader cannot act on, and nothing that leaks internals.
Remember this
- 1
The problem this solves: a chart box that shows nothing could mean it is still loading, there is no data, the request failed, or the code has a bug. The reader cannot tell, so they stop trusting the whole dashboard. On a wall display nobody is there to reload it.
- 2
The states a box can be in. Loading (first time, nothing to show yet), refreshing (has old data, fetching new), ok, empty (the answer is "nothing"), error (could not ask), partial (some boxes fine, some failed), and stale (showing old data because the refresh failed or the feed stopped). The function
stateOfin the second example turns four facts into one of them, so each box has exactly one. - 3
Skeletons. A skeleton is grey placeholders in the shape of the content. It keeps the layout from jumping when data arrives (the layout shift that Core Web Vitals call CLS) and shows that something is coming. It is decoration: the box also holds the sentence
Loading…, so a screen reader hears it, and the pulse stops for people who asked for reduced motion. - 4
Empty is not an error. Zero rows is a correct answer, so say why (
No days match these filters) and what to do (a Reset button). Do not mix up zero and missing:0rides on a day is data, a blank is no data. Treatnulland an empty array as empty, never as a drawing withNaNin it. - 5
Partial failure. Each box loads on its own and owns its own states. One wrong path (the example's
stations-2026.csvanswers 404) must not blank the rest. This is why the build gives every box its ownuseLoad, and whyPromise.allSettledsuits dashboards:Promise.allrejects as soon as one request fails and loses the others. - 6
Retries with backoff. Some failures pass: a server restarting, a dropped connection. Try again after 500 ms, then 1,000, then 2,000 (exponential backoff), and give up after a few tries so a broken server is not hammered. Do not retry 4xx answers: a 404 or 400 will not change. Add random jitter when many screens retry together. Only retry requests that are safe to repeat, such as GET.
- 7
Stale data is a state of its own. If a refresh fails but you have old numbers, keep showing them and say how old:
Showing old numbers. Last update 3 minutes ago.Replacing good old data with an error box helps nobody. And an old number that looks fresh is the worst failure: it is believed. - 8
Say it in plain words.
Could not load the station numbers (the server answered 404).beatsError, and a stack trace is for the console. Put the message in withtextContent, because an error text that came from a server must never become markup.
Explain it without notes
What is the difference between an empty state and an error state?
Why should one box's failure never blank the other boxes?
Why do retries use backoff, and which errors should not be retried?
When a refresh fails and you have old data, what should the box show?
Practice
Write ageText-style wording for a box whose data is null (never loaded) and one whose data is []. Which two states are they?
Make load call onGiveUp(problem) after the last failed try, so a page-level banner can say how many boxes failed. Where do you call it?
Trade-offs
- ↔
Skeletons need extra markup and CSS for every chart shape, but they prevent layout jumps. For a box that loads in under 200 ms, a skeleton flashes, so delay showing it a little.
- ↔
Showing stale data is helpful but can mislead if the label is missed. For safety-critical numbers, replace the value with a dash after a time limit.
Done when you can
I can name the states a box can be in and show each one.
I can make a skeleton that is announced as text and respects reduced motion.
I can let one box fail without touching the others.
I can retry with backoff and skip errors a retry cannot fix.
I can keep old data on screen, labelled with its age.
I can say in plain words what happened, using
textContent.