HomeJournalThis post

Building a resilient loading state

Loading UI should preserve layout, explain delay, and make recovery possible when the wait turns into failure.

JP
JP Casabianca
Designer/Engineer · Bogotá

Loading states are often designed as decoration: a spinner, a shimmer, a friendly sentence. But loading is not a visual state. It is a product state where the user is waiting for evidence that the system still works.

A resilient loading state does three jobs. It preserves layout, explains the wait when needed, and gives the user a path if the wait becomes a failure.

Reserve the real shape

The best skeleton is not gray confetti. It is a promise about where content will land.

If the loaded state is a list, the loading state should reserve list rows. If it is a chart and metric cards, reserve those regions. If it is a form, reserve labels and controls. Layout stability matters because users start orienting before data arrives.

When the loaded content appears, the page should feel filled in, not rearranged.

Match the expected duration

Not every wait deserves the same UI. A 200ms fetch may not need visible loading at all. A one-second fetch can use a quiet skeleton. A ten-second import needs progress, explanation, and possibly backgrounding.

I like a simple timing model:

  • Under 300ms: avoid flashing a loader.
  • 300ms to 2s: show structure.
  • 2s to 8s: add a plain status message.
  • Over 8s: offer cancellation, retry, or background processing.

The exact numbers vary, but the principle holds. The longer the wait, the more the product owes the user.

Do not fake progress precisely

Progress bars are useful when progress is real. They are harmful when they imply precision the system does not have.

If the job has known steps, show them. "Uploading", "Processing", "Checking results", "Done" is more honest than a fake 73 percent. If duration is unknown, say what is happening and keep the interface stable.

Users can tolerate waiting better than being tricked.

Preserve user input

The worst loading state is the one that erases work. Submitting a form should not clear the form until success is confirmed. Filtering a table should not destroy the previous results unless the new query succeeds or the transition is clearly intentional.

For risky operations, keep the previous useful state visible while the next state loads. A dashboard can dim stale data and mark it as updating. A table can keep old rows while showing that filters are changing. A form can disable only the controls that would conflict with the request.

Convert failure into recovery

Every loading state has a shadow state: failure. If the product does not design that shadow, users get stranded.

A good failure state says what happened, what is preserved, and what can happen next. "Could not load invoices. Your filters are still applied. Retry or clear filters." That is much better than "Something went wrong."

Retries should be local when possible. If one panel fails, do not force a full page reload. If one row action fails, keep the row and explain the next step.

A small implementation pattern

I prefer components that model loading, stale, empty, ready, and error as distinct states. Boolean isLoading is rarely enough once the product matures.

type LoadState<T> =
  | { status: "loading"; previous?: T }
  | { status: "ready"; data: T }
  | { status: "empty" }
  | { status: "error"; previous?: T; message: string };

That shape forces better rendering decisions. It asks whether previous data exists, whether empty is real, and whether recovery should be local.

Loading is not the boring part before the product appears. It is part of the product. Treat it that way and the interface feels more reliable immediately.