Part 7 · 2 chapters · ~20 min

Edge Cases, Degraded States, Failure Recovery

The nine failure cases every money screen meets (offline before and after, timeout, partial success, duplicate, stale data, expiry, server validation, the unknown error) each with a designed state, the states as a table the UI renders and the tests assert against, and the three sentences every degraded screen needs with the words that must never appear.

18

The catalogue

code
// the degraded states as a table the UI renders from and the tests assert against; a state without copy fails the build
export const TRANSFER_STATES = {
  offline_queued:   { what: ({ m }) => `Your transfer of ${m.amount} to ${m.to} will be sent when you are back online. The money is still in your wallet.`, next: 'Nothing to do; keep the app installed.', hold: ({ ref }) => `Reference ${ref}.`, retry: 'none' },
  confirming:       { what: ({ m }) => `We are confirming your transfer of ${m.amount} to ${m.to} with the bank.`, next: 'This usually takes a minute. You can leave this screen.', hold: ({ ref }) => `Reference ${ref}. We will notify you.`, retry: 'none' },
  unconfirmed:      { what: ({ m }) => `We could not confirm whether your transfer of ${m.amount} to ${m.to} went through.`, next: 'Check your transactions before sending again.', hold: ({ ref }) => `Reference ${ref}. Support can find it with this.`, retry: 'link:transactions' },
  failed_validation:{ what: ({ m, reason }) => `Your transfer of ${m.amount} to ${m.to} could not be completed: ${reason}. The money is still in your wallet.`, next: ({ field }) => `Check the ${field} and try again.`, hold: ({ ref }) => `Reference ${ref}.`, retry: 'same_key' },
  failed_permanent: { what: ({ m, reason }) => `Your transfer of ${m.amount} to ${m.to} was declined: ${reason}. The money is still in your wallet.`, next: 'Choose another account.', hold: ({ ref }) => `Reference ${ref}.`, retry: 'new_intent' },
  stale_balance:    { what: ({ m, now }) => `Your available balance is now ${now}; you entered ${m.amount}.`, next: 'Review the amount and confirm again.', hold: () => '', retry: 'review' },
  session_expired:  { what: () => 'Your session expired.', next: 'Sign in again to continue. Your transfer details are kept.', hold: () => '', retry: 'reauth' },
  partial:          { what: ({ ok, failed }) => `${ok} of ${ok + failed} payments were sent.`, next: ({ failed }) => `Retry the ${failed} that failed.`, hold: ({ ref }) => `Batch ${ref}.`, retry: 'per_part' },
  unknown_error:    { what: ({ m }) => `We do not know yet whether your transfer of ${m.amount} to ${m.to} went through.`, next: 'We are checking. Do not send it again yet.', hold: ({ ref }) => `Reference ${ref}. We will notify you.`, retry: 'none' },
} satisfies Record<string, DegradedState>

// the test (one per state): render(<TransferResult state={s} ctx={fixture} />); expect three sentences; expect amount and ref; expect whereabouts when not 'confirming';
//   expect(screen.queryByText(/something went wrong|try again later|oops|error \d+/i)).toBeNull()
nine cases, nine designed states
  1. Offline before submit: the form works; lookups degrade with a note; the submit queues the intent with its key (part 3) in IndexedDB and says "we will send this when you reconnect"; on reconnect it submits and shows pending. Never a dead button, never a lost form (the FSD course M5's local-first idea).
  2. Offline after submit, and timeout: the response may be lost and the action may have happened. "We are confirming" with the reference; a poll by key; "failed" only when the server says so; after retries, "we could not confirm: check your transactions" with a link, never a silent drop. The most important state to design: duplicates and lost money both live here.
  3. Partial success: each part with its own result and next action ("2 of 5 payments failed: retry those"), the retry per part with its own key; never "something went wrong" over a mixed result; never a retry of the whole batch.
  4. Duplicate submit: solved by the key. Stale data: the server refuses with the current value ("your available balance is now ₦380,000; you entered ₦420,000") and the review re-renders with the new number highlighted; every submit is validated against now and the user sees the now.
  5. Session expiry mid-flow: a re-authentication modal over the current step with the form kept (part 1). Server validation the client did not predict (a new limit, a sanctioned name, a closed account): a field and a reason; land the user on the field; the review step is not skipped on the retry.
  6. The unknown server error (a 500, a gateway timeout, a malformed response): treat as the timeout case; "we do not know yet", in those words; record the error with the key, reference and breadcrumbs (the Architecture course part 3) so support finds it from the user's reference.
THE CATALOGUE
nine failure cases every money screen meets, and the designed state for each
swipe the figure sideways, or tap expand for full screen
1/6
offline before
Offline before submit: the user fills the form on a train. The form works (validation is local; name lookup degrades to "we will check this when you are back online"); the submit button says "you are offline: we will send this when you reconnect" and queues the intent with its idempotency key (part 3) in IndexedDB; on reconnect it submits and shows pending. Never a dead button; never a lost form.
19

What the screen says

three sentences
  1. What happened: amount, counterparty, outcome, and the money's whereabouts ("Your transfer of ₦250,000.00 to ADEOLA FAITH could not be completed. The money is still in your wallet."). The whereabouts sentence is the one the user is reading for.
  2. What next: one action, named, with a when: "Check the account number and try again"; "We are confirming with the bank; this usually takes a minute"; "Sign in again to continue; your details are kept"; "We will send it when you are back online". Never two actions; never "later" without a when.
  3. What to hold on to: the reference and the promise ("Reference TRF-8K2Q9. We will notify you."). The reference outlives the screen and works with support; the promise is kept by the push (the FSD course M7).
the wrong words, the retry, the test
  1. Forbidden: an error code alone; "something went wrong"; "transaction failed" over a timeout; "invalid" without the field; "unauthorised" for an expired session; "your request" for a transfer; "Oops!" over money. Each has a predictable effect: confusion, fear, or the belief the money is gone.
  2. The retry button exists only when safe (the key) and says what it does ("Send again"); it is absent while confirming (the system is retrying) and replaced by a different action when the failure is permanent ("Choose another account").
  3. Copy is code: the states are a table; the UI renders from it; a test per state renders it and asserts the three sentences, the amount and reference, the whereabouts, and the absence of every forbidden phrase (the Architecture course part 2's thick middle). A state without copy fails the build.
the exercise
Cut the network on your product's riskiest screen at each of the nine moments and screenshot what it says. Score each screenshot against the three sentences and the forbidden list. The ones that fail are the catalogue, unapplied.
WHAT THE SCREEN SAYS
the three sentences every degraded state needs, and the words that make an anxious user more anxious
swipe the figure sideways, or tap expand for full screen
1/6
what happened
Sentence one, what happened: "Your transfer of ₦250,000.00 to ADEOLA FAITH could not be completed. The money is still in your wallet." The amount, the counterparty, the outcome, and where the money is. The whereabouts sentence is the one the user is reading for; put it first when it is good news and second when it is not.