PAUL CHONGSenior Software Engineer

Error States & Loading Indicators

2026-08-08 · 6 min read

Every data-fetching surface in a UI has four possible states: loading, success, error, and empty. Most designs only show the success path. The other three are where users lose trust.

Loading states

Spinners

A spinner communicates "something is happening" but nothing else — no sense of how long, no sense of what the page will look like when it loads. For anything beyond a button state, it's the wrong choice.

The one place a spinner is appropriate: a small, inline indicator next to the specific thing loading. A full-page spinner for a content-heavy page is the worst option.

Skeleton screens

A skeleton screen is a placeholder that mimics the shape and layout of the real content before it arrives.

Loading:                    Loaded:
┌──────────────────────┐    ┌───────────────────────┐
│ ████ ██████████████  │    │ [img] Post title here │
│ ███████████████████  │    │ Lorem ipsum dolor...  │
│ ████████████         │    │ 3 min read            │
└──────────────────────┘    └───────────────────────┘

Skeletons are better than spinners for three reasons:

  1. They reserve space. The page doesn't jump and reflow when content loads — Cumulative Layout Shift (CLS) stays low.
  2. They set expectations. The user understands the shape of what's coming.
  3. They feel faster. Perceived load time drops even if actual load time is identical — the user sees progress instead of a waiting state.

Shimmer animations

A shimmer is an animated gradient sweep across skeleton elements that signals active loading rather than a frozen UI.

@keyframes shimmer {
  0%   { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}

.skeleton {
  background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

Always honor prefers-reduced-motion — users who opt into reduced motion can be sensitive to looping animations. Fall back to a static skeleton:

@media (prefers-reduced-motion: reduce) {
  .skeleton {
    animation: none;
  }
}

Progressive loading

Show what you have immediately and load the rest asynchronously. Don't wait for everything before showing anything.

The most common example is image placeholders. Instead of showing nothing while a high-resolution image downloads, show a blurred low-quality version first:

  • LQIP (Low Quality Image Placeholder): A tiny (e.g. 20×20px) version of the image, scaled up and blurred. Embedded as a base64 data URL so it loads inline with the HTML — no extra round trip.
  • BlurHash: A compact string representation of a blurred image preview, decoded client-side into a canvas element. Smaller than LQIP, no base64 bloat.
<div style={{ position: 'relative' }}>
  {/* Placeholder shown immediately */}
  <img
    src={blurDataUrl}
    style={{ filter: 'blur(20px)', transform: 'scale(1.1)' }}
    aria-hidden
  />
  {/* Full image fades in on load */}
  <img
    src={fullImageUrl}
    onLoad={(e) => e.target.style.opacity = 1}
    style={{ opacity: 0, transition: 'opacity 0.3s', position: 'absolute', inset: 0 }}
  />
</div>

The same principle applies beyond images: render the above-the-fold content immediately, defer loading secondary panels, sidebars, and below-the-fold sections.

Optimistic UI

For writes, skip the loading state entirely by showing the result immediately before the server responds. See Optimistic Updates for the full pattern.

Error states

Transient errors

Transient errors are temporary — a network blip, a 503, a timeout. The right response is an inline error with a retry button near the failed action.

┌─────────────────────────────────────────┐
│  Couldn't load comments.  [Try again]   │
└─────────────────────────────────────────┘

Key principles:

  • Inline, not modal. Show the error where the failure happened. A modal for a failed comment load is disproportionate.
  • Don't collapse the page. If the sidebar fails to load, the main content should still be usable. Isolate the failure to the component that owns it.
  • Retry should be one tap. The user shouldn't have to reload the entire page to retry one request.

For mutations (submitting a form, liking a post), show a toast or inline message near the action and re-enable the control so the user can try again.

Permanent errors

Permanent errors require a different response — the retry will never succeed.

  • 401 Unauthorized / 403 Forbidden: Redirect to login or show a permissions message. Don't show a generic error.
  • 404 Not Found: Show a meaningful not-found page. If the resource was deleted, say so.
  • 4xx validation errors: Surface the specific validation message next to the field, not a generic "something went wrong."

The distinction matters: showing a retry button for a 403 wastes the user's time.

async function loadPost(id) {
  const res = await fetch(`/api/posts/${id}`);

  if (res.status === 401) return redirect('/login');
  if (res.status === 403) return setError('You do not have access to this post.');
  if (res.status === 404) return setError('This post does not exist or was deleted.');
  if (!res.ok) return setError('Something went wrong.', { retry: true }); // transient
}

Graceful degradation

When fresh data can't be loaded, show stale data rather than a blank error page. Users can still read what they last saw — they lose nothing except freshness.

┌─────────────────────────────────────────────────┐
│  ⚠ Couldn't refresh. Showing last saved data.   │
└─────────────────────────────────────────────────┘
[stale content renders normally below]

This requires the client to have a cache to fall back to — see Client-Side Caching and HTTP stale-while-revalidate. The pattern: always attempt a fresh fetch, but render whatever is in cache immediately, and update the UI if the fetch succeeds. If it fails, keep the cached version and surface a non-blocking banner.

TanStack Query does this with placeholderData or by keeping the previous query data visible while a background refetch is in flight.

Empty states

An empty state is not an error — it's a valid, expected state that's often neglected in design.

First-time users: No content yet because the user hasn't created any. This is an onboarding opportunity, not a blank page.

You haven't written any posts yet.
[Write your first post →]

Search with no results: Tell the user what you searched for and give them a path forward.

No results for "blurhahs". Check your spelling or try a broader term.

Feed with no new content: Confirmation, not emptiness.

You're all caught up. Check back later.

The empty state should match the tone of the product and give the user something to do. A blank white area with no explanation is worse than no state at all — it looks broken.

The four states

For every data-fetching surface, design all four:

StateWhat to show
LoadingSkeleton screen matching the content shape
SuccessThe content
ErrorInline message + retry (transient) or clear explanation (permanent)
EmptyContextual message + a path forward

Candidates who only design the success path in a system design interview lose points. Interviewers specifically look for whether you account for loading, error, and empty — they signal that you've shipped real products and thought about the full user experience.