Async feedback · 7 min read

The spinner comes second

A click deserves an answer before the server has one.

A click creates a promise

A network request starts after a click. The interface starts earlier: the moment the input lands. If nothing changes until the response returns, the user cannot tell a slow request from a missed click. They click again, move on, or lose trust.

Chrome’s RAIL guidance aims for a visible response to input within 100 milliseconds. That response does not need to be a loader. It can be a pressed control that stays acknowledged, a changed label, a pending row, or the result itself when it arrives quickly enough.

Feedback begins with acknowledgement. Loading is what comes next when acknowledgement has to last.

Profile 0 requests
Display name Dani Asyrofi
Ready
Profile 0 requests
Display name Dani Asyrofi
Ready
The network is equally slow. Only one interface confirms that the click landed.
Delay

Delay has phases

“Loading” is too coarse a state. A fast result, a save that takes a second, and a twelve-minute upload should not share one treatment. Jakob Nielsen’s long-running response-time heuristics place useful landmarks near 0.1, 1, and 10 seconds. Treat them as a mental model, not universal service-level targets.

  • Near 100 ms Let the result be the feedback. Preserve the control’s ordinary pressed state.
  • A short wait Acknowledge the action and prevent accidental duplication. A spinner may still be unnecessary.
  • Beyond a second Name the work where it is happening: “Saving changes” is better than “Loading.”
  • Long, measurable work Show real progress and, when it is safe, offer Pause or Cancel.

A 200 ms loader delay is a useful starting point for an experiment, not a law. Tune it against the latency distribution and importance of the action you actually ship.

Do not erase useful context

An eager loader can make a fast request feel slower. It removes content, flashes a new shape, and asks the reader to reconstruct their position when the content returns. During refresh, the existing content is usually more useful than an empty surface.

For an initial load, a skeleton is appropriate only when it represents a structure you genuinely know. IBM’s Carbon loading pattern limits skeletons to container- and data-based components such as lists, cards, and tables—not buttons, menus, or the loader itself. When content already exists, keep it and mark the affected region busy.

When progress is measurable, prefer to report it honestly. Apple’s progress-indicator guidance recommends determinate progress when possible because it lets people estimate the wait and decide whether to continue, cancel, or do something else.

WorkUseful feedback
Known initial structureA geometry-matched skeleton
Refresh of visible dataKeep content and show local pending state
Unknown durationAn indeterminate indicator with specific copy
Measurable durationDeterminate progress, accurate value, and Cancel when safe
Recent projects Up to date
AAtlasEdited 4 min ago
NNorthstarEdited yesterday
PParcelEdited Monday
Recent projects Up to date
AAtlasEdited 4 min ago
NNorthstarEdited yesterday
PParcelEdited Monday
A loader is feedback. A flashing loader is noise.
Delay

Optimism is a risk budget

Optimistic UI moves the interface before the server confirms the change. React’s useOptimistic documentation describes that value as temporary: while the action is pending it renders immediately, then converges with the real state or falls back when the action fails.

That mechanism is not permission to make every mutation optimistic. The practical boundary is risk. A bookmark, preference, or reorder is easy to reverse. A payment, permanent deletion, or conflicting edit needs explicit pending and confirmation because pretending it succeeded creates a more expensive failure.

Reversibility, failure cost, and conflict handling are design judgments. They are more useful than a blanket “optimistic is faster” rule.

Notifications Account
Weekly summarySent every Monday
Off
Notifications Account
Weekly summarySent every Monday
Off
Optimistic updates borrow certainty from the future. Failure has to pay it back.
Response

Failure completes the contract

Pending is not finished until the interface handles failure. Keep user input, restore an optimistic value when necessary, explain what did not happen, and put Retry beside the affected object. A disappearing toast is weak recovery for a state the user must repair.

Status must also exist beyond pixels. WAI-ARIA defines aria-busy for regions being updated and role="status" as a polite live region that should not receive focus. “Saving changes,” “Changes saved,” and “Couldn’t save” can therefore be announced without pulling a keyboard or screen-reader user away from their work.

Finally, a disabled button is not a transaction guarantee. The GOV.UK button guidance recommends visible feedback on slow connections and explicitly notes that duplicate protection must also be considered server-side.

A practical default

Start with a small state machine. Acknowledge immediately, delay only the decorative loader, keep the affected region marked as busy, and close every path with a useful result.

// A starting point, not a universal threshold.
const loaderDelay = 200;
let pending = false;

async function save() {
  if (pending) return;

  pending = true;
  button.setAttribute("aria-disabled", "true");
  region.setAttribute("aria-busy", "true");

  const reveal = setTimeout(() => {
    button.dataset.waiting = "true";
    status.textContent = "Saving changes…";
  }, loaderDelay);

  try {
    await saveChanges();
    status.textContent = "Changes saved.";
  } catch {
    status.textContent = "Couldn’t save. Try again.";
  } finally {
    clearTimeout(reveal);
    delete button.dataset.waiting;
    button.removeAttribute("aria-disabled");
    region.setAttribute("aria-busy", "false");
    pending = false;
  }
}
  • FastTest a result that returns before the loader threshold.
  • SlowTest the moment pending feedback appears and a genuinely long wait.
  • FailureForce rollback, preserve input, and retry without losing context.
  • Repeat inputDouble-click and press Enter twice; enforce idempotency for consequential mutations.
  • AccessVerify focus, status announcements, keyboard use, and reduced motion.

Speed is partly a backend property. Responsiveness is an interface decision.

Use it as a skill

Install the field guide when an interface needs intentional pending, optimistic, progress, or recovery states.

npx skills add daniasyrofi --skill async-ui-feedback