Wait UX
Waiting can't be avoided, but how it feels can be designed. Pick the right signal for each duration, keep loaders from flashing, keep content still, and always offer a way out when something fails.
Perception thresholds
Users don't measure milliseconds; they perceive time. Each duration needs its own kind of signal. A strong signal for a fast task is as bad as silence for a slow one.
| Duration | How it feels | What to show | In Yugen |
|---|---|---|---|
| < 100ms | Instant, like flipping a switch. | Nothing; show the result directly. | Hover / pressed states of controls. |
| 100ms – 1s | A delay, but the train of thought holds. | No loader, or a light in-place signal (after a delay). | useSpinDelay |
| 1 – 3s | The wait becomes noticeable. | An in-place spinner, or a skeleton if the layout is known. | Button loading · AsyncContent · Skeleton presets |
| 3 – 10s | Attention starts to drift. | Determinate progress, plus what is happening and how long is left; allow cancel. | ProgressBar · PageProgress value |
| > 10s | The user moves on to something else. | Run in the background, let them leave, notify when done. | toast · useAnnounce |
Pick a duration and press “Generate report”. At 80ms the result appears at once; at 600ms there is only a small spinner in the button (shown after 300ms); at 2s a skeleton; at 6s a progress bar with the time left; at 12s the work moves to the background and a toast reports when it is done.
Choose an indicator
Go from the top and stop at the first “yes”. The options can be combined: Button loading for the button just pressed, PageProgress for the page that is loading.
- 1Does the action almost always succeed, and can it be undone (like, check off a task, rename)?YesOptimistic UI
useOptimisticList - 2Do you know the real progress (bytes sent, steps completed)?YesDeterminate progress
ProgressBar · PageProgress value - 3Is a page navigation or an app-wide background load in progress?YesTop edge bar
PageProgress · pageProgress.start() - 4Do you know the layout of the content that is coming (list, cards, table)?YesSkeleton
AsyncContent + Skeleton presets - 5Is the wait tied to a control that was just pressed?YesIn-place loading
Button loading - 6Anything elseSpinner + label, after a delay
LoadingOverlay · Spinner + useSpinDelay
Delay & minimum duration
The two most common loader bugs: it flashes on and off for fast responses, and it flickers when the data arrives just after the loader appears. useSpinDelay fixes both.
Not loaded
Not loaded
At 120ms, the left column flashes the skeleton for an instant: the eye sees that something jumped but has no time to read it. At 350ms, the right column waits 300ms before it shows and then stays for the full 500ms, so although the data arrives at 350ms, the user sees one complete transition instead of a blink. From 700ms up, both columns show the loader, but the right one never shows it for less than 500ms.
| Prop · Type | Default | Description |
|---|---|---|
| loadingboolean | Default: — | The actual (raw) loading state. |
| delaynumber | Default: 300 | If the response arrives before this, the loader never shows. |
| minDurationnumber | Default: 500 | Once the loader shows, it stays at least this long. |
| (returns)boolean | Default: — | true when the loader should be rendered. |
import { useSpinDelay, Spinner } from "@/components/ui";
function Orders({ query }: { query: Query }) {
const { data, isFetching } = useOrders(query);
// < 300ms: never shown · once shown: kept for ≥ 500ms
const showSpinner = useSpinDelay(isFetching, { delay: 300, minDuration: 500 });
return (
<section aria-busy={isFetching}>
{showSpinner && <Spinner size="tiny" label="Updating…" />}
<OrderTable rows={data} />
</section>
);
}While the delay runs, keep the previous UI in place instead of replacing it with an empty area. AsyncContent does this for you, and LoadingOverlay takes a delay so it only covers the old content when the refresh is actually slow.
October revenue
₫128.4M
Up 12% from last month
Skeleton fidelity
A skeleton promises the layout that is coming. If the promise is wrong (a different column count, a bigger avatar, a button somewhere else), everything jumps when the content arrives, which is worse than no skeleton at all.
- An NguyenDesigner
- Binh TranFrontend engineer
- Chi LeProduct manager
- Same shape and size: a 40px avatar gets a 40px SkeletonAvatar; 44px table rows get a SkeletonTable with 44px rows.
- Roughly the same count: 3–5 rows are enough. Don't draw 50 rows for a list that usually has 8 items.
- Only stand in for what is missing. Show the page title, column labels and navigation buttons you already know right away.
- Don't put a spinner on top of a skeleton. Use one or the other.
- Under 300ms no skeleton is needed: let AsyncContent / useSpinDelay skip it.
Optimistic UI & undo
For actions that almost always succeed, don't make the user wait for the server. Change the UI at once, send the request in the background, and roll back only if it fails, with an explanation.
- Xuan Huong Lake at dawn
- Cau Dat tea hills
- Cherry blossoms on the slope
Turn on “Simulate a network error” and star a few photos in a row. The star changes at once; after 1.2 seconds each failed change is rolled back on its own (the other changes stay), and a toast that does not close by itself explains what happened and offers a retry button.
import { useOptimisticList } from "@/components/ui";
const { items, mutate, pending } = useOptimisticList<Photo>(initialPhotos);
// 1. update is applied to the UI at once; write it by id, not by position
// 2. commit runs in the background; on error only this change is rolled back and a toast with a retry button appears
const ok = await mutate(
(list) => list.map((p) => (p.id === id ? { ...p, starred: !p.starred } : p)),
() => api.photos.star(id),
{ errorTitle: "Couldn't star the photo" },
);| Good fit for optimistic UI | Must wait for the server |
|---|---|
| Like, star, check off a to-do | Payments, money transfers, bookings |
| Reorder, rename, tag | Permanent delete, actions that can't be undone |
| Send a comment or message (show “sending”) | Results the server decides (stock checks, authentication) |
Cancel & timeout
A long wait must be cancelable and must not go on forever. When the user cancels, stay silent; on a timeout, say that it took too long and offer a next step.
useEffect(() => {
const controller = new AbortController();
// Cancel when the user leaves the screen or changes the filter, and stop on its own after 10 seconds.
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]);
fetch(`/api/orders?q=${query}`, { signal })
.then((res) => res.json())
.then(setOrders)
.catch((error) => {
if (error.name === "AbortError") return; // cancelled on purpose: stay silent
if (error.name === "TimeoutError") setStatus("timeout"); // say that it took too long
else setStatus("error");
});
return () => controller.abort();
}, [query]);- From about 3 seconds up, always put a Cancel button next to the progress indicator.
- Cancel the old request when the user changes a filter or keeps typing, so a late result never overwrites a newer one.
- A timeout is its own state, not an “unknown error”: suggest trying again or running in the background.
AbortSignal.anyandAbortSignal.timeouthave been available in all evergreen browsers since 2024.
Errors & retry
Any wait can end in an error. A good error state answers three questions: what happened, whether any data was lost, and what to do next.
Connection lost
Couldn't load the comments. The post itself is still readable.
The retry button, labelled “Thử lại” (Retry) by default, takes a Promise and goes into its loading state by itself; focus stays on the button the whole time. The first retry still fails (a toast explains why), the second one succeeds.
- Keep the old data when a refresh fails, and report the error with a toast or MessageBar instead of clearing the screen.
- Retry transient errors automatically 2–3 times with backoff and jitter; show ErrorState only after the retries run out.
- Keep the error inside the broken area. One failing widget should not take down the whole page.
- Write the title in the user's language; technical error codes (if needed) go in the description or behind “Report a problem”.
// Automatic retry for transient errors (network, 5xx, 429), with backoff and a limit.
async function withRetry<T>(task: () => Promise<T>, { retries = 3, base = 500 } = {}) {
for (let attempt = 0; ; attempt++) {
try {
return await task();
} catch (error) {
if (attempt >= retries || !isTransient(error)) throw error;
const wait = base * 2 ** attempt * (0.5 + Math.random()); // 500 → 1000 → 2000ms, with jitter
await new Promise((r) => setTimeout(r, wait));
}
}
}
// Automatic retries used up → hand control to the user
<AsyncContent status={status} onRetry={reload} skeleton={<SkeletonList rows={5} />}>
{() => <MemberList members={data!} />}
</AsyncContent>Accessibility
Screen reader users don't see a spinner turning. The wait has to be announced, briefly and at the right moment, and must never take focus away.
| Principle | How | Built into |
|---|---|---|
| Mark the busy region | Set aria-busy="true" on the region being updated and remove it when done. | LoadingOverlay, AsyncContent, Skeleton |
| Announce waits that last | A polite announcement, “Đang tải…” (Loading…), when the loader actually shows (after the delay); nothing for fast responses. | LoadingOverlay, AsyncContent (useAnnounce) |
| Read errors right away | role="alert" for an error after an action the user is waiting on. | ErrorState variant="alert" (the default in AsyncContent) |
| Don't move focus | A busy button stays focusable (aria-disabled, not disabled); when content is replaced, focus stays in the region. | Button loading, AsyncContent |
| Respect reduced motion | No shimmer, no width animation and no fade with prefers-reduced-motion. | Skeleton, PageProgress, LoadingOverlay |
{/* 1. Loading region: aria-busy so assistive technology waits for the update before reading */}
<section aria-busy={isLoading}>…</section>
{/* 2. Announce in words when the wait is long enough to show a loader (LoadingOverlay and AsyncContent do this) */}
const { announce } = useAnnounce();
announce("Loading orders…");
announce(`Loaded ${count} orders`, { batchId: "orders" });
{/* 3. A busy button keeps focus: aria-busy + aria-disabled, not disabled */}
<Button appearance="primary" loading={saving} loadingText="Saving…">Save</Button>
{/* 4. An error after an action the user is waiting on: role="alert" */}
<ErrorState variant="alert" onRetry={retry} />A progress bar only exposes aria-valuenow when the progress is real. In trickle (estimated) mode, PageProgress leaves the value empty so screen readers don't read out a made-up number.
Do and don't
- Pick the signal by the expected wait: nothing · in place · skeleton · progress · background.
- Wrap every loader in useSpinDelay (300ms / 500ms).
- Keep the old content and the layout still while waiting.
- Let users cancel long waits, set a timeout, and always offer a way out after an error.
- Blocking the whole page with an overlay for a local action.
- An indeterminate spinner for work whose progress you know.
- Rolling back an optimistic change without telling the user.
- Moving focus to the loader, or disabling the button that is busy.