Command Palette

Search for a command to run...

Hectal
PHASE 16Advanced ~43 min· topic 4 of 8

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.allSettled waits 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. 1

    Change the wrong path

    In the first example, change stations-2026.csv to stations.csv. The Stations box loads: stations: loaded 25 rows, and only the partner feed stays broken.

  2. 2

    Allow more tries

    Give withRetry the option tries: 5 in load. 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

Five boxes, five outcomes, one page that survives New tab

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

Press Run to see the page.

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: 2
Which state is this box in? New tab

The 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

Press Run to see the page.

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 fetch

Break 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.

terminal
$ Console, and in the page
── what you'll see ──
Uncaught TypeError: Failed to fetch
(or Error: Could not load stations-2026.csv (the server answered 404), whichever failure reaches Promise.all first)
(the Stations and Partner feed boxes stay on their grey bars and "Loading…" for ever, with no error text and no Try again button)

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 GET is; a POST that 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 is role="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. 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. 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 stateOf in the second example turns four facts into one of them, so each box has exactly one.

  3. 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. 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: 0 rides on a day is data, a blank is no data. Treat null and an empty array as empty, never as a drawing with NaN in it.

  5. 5

    Partial failure. Each box loads on its own and owns its own states. One wrong path (the example's stations-2026.csv answers 404) must not blank the rest. This is why the build gives every box its own useLoad, and why Promise.allSettled suits dashboards: Promise.all rejects as soon as one request fails and loses the others.

  6. 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. 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. 8

    Say it in plain words. Could not load the station numbers (the server answered 404). beats Error, and a stack trace is for the console. Put the message in with textContent, because an error text that came from a server must never become markup.

Explain it without notes

01

What is the difference between an empty state and an error state?

02

Why should one box's failure never blank the other boxes?

03

Why do retries use backoff, and which errors should not be retried?

04

When a refresh fails and you have old data, what should the box show?

Practice

01

Write ageText-style wording for a box whose data is null (never loaded) and one whose data is []. Which two states are they?

02

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.