Skip to content

feat(kit): tell somebody the API is waking, instead of failing at them - #65

Merged
Bccorb merged 2 commits into
mainfrom
feat/waking-state
Aug 26, 2026
Merged

feat(kit): tell somebody the API is waking, instead of failing at them#65
Bccorb merged 2 commits into
mainfrom
feat/waking-state

Conversation

@Bccorb

@Bccorb Bccorb commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Closes #60.

This is the front end half of scaling an idle application's task to zero. The infrastructure half is tracked in seamless-terraform-service; this is the half that decides whether the trade is acceptable to a person.

The problem, restated

An application that scales to zero is woken by the first request after an idle spell, and that takes tens of seconds. The front end had nothing to say about it, so a sleeping API produced whatever a failed fetch produces. To a non-technical visitor that is a broken link.

The person who meets the cold path is never the owner. The owner's first view happens seconds after the build, while the task is warm. The cold path belongs to somebody who was sent a link days later, has never seen the application before, and is judging the whole thing on that one screen.

Telling a wake from a fault

apiFetch now throws an ApiError with the status and a waking flag.

Failure waking Why
502, 503, 504 yes A load balancer with nothing healthy behind it yet
fetch rejected outright yes See below
offline (navigator.onLine === false) no The browser is certain about this one
any other status no A real fault
missing VITE_API_URL not an ApiError at all A configuration mistake, not a wait

The second row is the one worth reading twice. A load balancer's own 503 is generated by the load balancer, so it carries none of the API's CORS headers, and the browser refuses to let the page read it. It arrives here as a rejected fetch with no status, indistinguishable from no answer at all. Treating only the status codes as a wake would have missed the most likely shape of the real thing.

The last row matters just as much in the other direction: if a missing origin were dressed up as an API that is about to answer, a misconfigured build would sit on a patient waiting screen forever. That case stays the plain error it always was, and there is a test pinning it.

Waiting it out

A first load that fails as a wake puts the collection into a new waking state and retries with backoff (1s, 2s, 4s, then 8s), giving up after about a minute and a half, which is comfortably longer than a cold start. Nobody presses anything; it recovers on its own.

Three details that are easy to get wrong and are each pinned by a test:

  • a retry keeps the waking state rather than flashing the skeleton back up, because it is the same wait, not a new one;
  • the pending retry is cancelled on unmount and on a path change;
  • giving up resets the window, so a retry button buys the whole wait again rather than an instant refusal.

What it looks like

LoadState gains waking; RecordList and DataTable render a new WakingState. Not a skeleton, which promises "nearly there", and not an error, because nothing is wrong:

Waking this up
It sleeps while nobody is using it, so the first visit takes a few seconds. Nothing to do; it carries on by itself.

It is a role="status" with aria-live="polite", and it takes no props on purpose: every generated application should say this the same way, and the reader has no idea what a cold start is.

Checked in the browser in both palettes, desktop and at 375px, since this screen is the first impression of the whole company for every non-owner.

The first commit: src/lib joins the shared source of truth

This PR has to change apiFetch, which lives in src/lib. That directory was byte for byte identical in both React starters with nothing keeping it that way, and the kit already imports it (useCollection reaches for ../../lib/api), so a change to what apiFetch throws is a change to what the kit has to handle. Maintaining that across two unlinked copies is drift waiting to happen.

The first commit is a pure move plus one line of sync.json, so npm run validate now fails on an edit made to a template's copy, the same as for the kit. No file contents change in it. Reviewing it separately from the feature should be quick.

Tests

19 new assertions across three files: the ApiError classification table above, the hook's wake behaviour (waits rather than fails, retries without being asked, keeps saying waking while it retries, gives up eventually, stops on unmount, and goes straight to an error for a 404), and RecordList rendering the notice rather than a skeleton or a fault.

They were mutation checked rather than only run green. Making nothing classify as waking fails four of them; never closing the wake window, not cancelling the retry on unmount, and letting a retry reset the state to loading each fail exactly one. That last mutation initially passed, which is why the "keeps saying it is waking while it retries" test exists.

Checks

npm run validate passes, and npm run check is green in both templates (react-vite 30 tests, react-oauth 34 tests).

Bccorb added 2 commits August 25, 2026 23:59
`src/lib` was byte for byte identical in both React starters and nothing kept it
that way. The kit already depends on it, since `useCollection` imports
`../../lib/api`, so a change to what `apiFetch` throws is a change to what the
kit has to handle, and maintaining that across two unlinked copies is a drift
waiting to happen.

The manifest now lists `lib`, so `npm run validate` fails on an edit made to a
template's copy instead of to the source, the same as for the kit. No file
contents change; this is a move plus one line of manifest.
Scaling an idle application's task to zero is the largest cost lever the product
has, and the price is a first request that takes tens of seconds. The front end
had nothing to say about that: a sleeping API produced whatever a failed fetch
produces, which to a visitor is indistinguishable from a broken link. The person
who meets it is never the owner, whose first view happens while the task is
still warm. It is somebody who was sent a link days later and has never seen the
application before.

apiFetch now throws an ApiError carrying the status and a `waking` flag. A 502,
503 or 504 is a load balancer with nothing healthy behind it yet, and so is a
fetch the browser rejected outright, because a load balancer's own 503 carries
none of the API's CORS headers and so arrives as no answer rather than as a
status. Being offline is ruled out, and a missing origin stays the plain
configuration error it was, since waiting patiently for a misconfigured build is
a wait that never ends.

useCollection waits a wake out. It enters a new `waking` state and retries with
backoff, capping at eight seconds and giving up after about a minute and a half,
so it recovers on its own with nobody pressing anything. Retries keep the waking
state rather than flashing the skeleton, the pending retry is cancelled on
unmount and on a path change, and giving up resets the window so a retry button
buys the whole wait again.

LoadState gains `waking`, and RecordList, DataTable and a new WakingState render
it: a calm panel saying the application sleeps while nobody is using it, that
this takes a few seconds, and that there is nothing to do.
@Bccorb
Bccorb merged commit 1d07b3e into main Aug 26, 2026
3 checks passed
@Bccorb
Bccorb deleted the feat/waking-state branch August 26, 2026 04:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An honest waking state, so an idea application can scale to zero

1 participant