2025-05-30 4 min read

Skeleton Screens vs Spinners vs Optimistic UI

Three loading patterns. Three different jobs. Learn when to use skeleton screens for perceived performance, spinners for clarity, and optimistic UI for instant feedback.

Loading states matter more than most teams realize. A user waiting three seconds feels the wait differently depending on what they see. A spinning circle feels longer than a skeleton screen mimicking your actual layout. An interface that responds instantly to their action feels complete before data arrives. These aren't minor UX tweaks—they're the difference between an app that feels polished and one that feels sluggish.

But which pattern should you actually use? The answer depends on your specific context, not on trends or defaults.

Skeleton Screens: Use When Showing Real Content Matters

Skeleton screens display a placeholder structure that matches your final content layout. They work best when users need immediate visual confirmation of what's loading.

When skeleton screens win

Use skeleton screens for:

  • Content feeds (social media, news, product lists) where layout context helps users understand what's arriving
  • Detail pages where seeing the approximate shape of incoming data reduces cognitive load
  • High-latency loads where you're confident about the exact layout that will render

Example implementation

typescript
function ProductCard({ isLoading }: { isLoading: boolean }) {
  if (isLoading) {
    return (
      <div className="product-card">
        <div className="skeleton skeleton-image" />
        <div className="skeleton skeleton-title" style={{ width: '80%' }} />
        <div className="skeleton skeleton-price" style={{ width: '40%' }} />
      </div>
    );
  }
  
  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>${product.price}</p>
    </div>
  );
}

The skeleton mimics the exact structure users will see, making the transition seamless.

Spinners: Use When Context Is Unclear

A loading spinner is honest and non-committal. It says: "Something is happening." That simplicity is its strength, not weakness.

When spinners are appropriate

Spinners work best when:

  • Layout is unpredictable (responses vary significantly in structure or height)
  • Loading time is genuinely uncertain (queries might take 500ms or 5 seconds)
  • Users perform async actions (form submissions, file uploads where the result structure isn't pre-known)
  • You're building internal tools where perceived performance matters less than clarity

Why spinners aren't lazy

At LavaPi, we've seen teams avoid spinners thinking they're "old fashioned." That's misguided. A spinner is the right choice when you can't authentically represent what's coming. Fake skeleton layouts that don't match the actual content create a worse experience than honest loading indication.

typescript
function AsyncActionButton({ onSubmit }: { onSubmit: () => Promise<void> }) {
  const [isLoading, setIsLoading] = useState(false);
  
  const handleClick = async () => {
    setIsLoading(true);
    try {
      await onSubmit();
    } finally {
      setIsLoading(false);
    }
  };
  
  return (
    <button onClick={handleClick} disabled={isLoading}>
      {isLoading ? <Spinner /> : 'Submit'}
    </button>
  );
}

Optimistic UI: Use When You Control the Data

Optimistic updates assume the request will succeed and show the new state immediately, rolling back only if something fails. This creates the snappiest experience possible.

When optimistic UI shines

Use optimistic updates for:

  • Simple CRUD operations (toggling a like, adding an item to a list)
  • Predictable state changes where you know exactly what the response will be
  • High-frequency interactions where latency is most noticeable
  • Cases where rollback is graceful (users can retry easily)

Example: Optimistic like button

typescript
function LikeButton({ postId, initialLiked }: Props) {
  const [liked, setLiked] = useState(initialLiked);
  const [isPending, setIsPending] = useState(false);
  
  const handleToggle = async () => {
    const previousLiked = liked;
    setLiked(!liked);
    setIsPending(true);
    
    try {
      await toggleLike(postId, !previousLiked);
    } catch (error) {
      setLiked(previousLiked); // Rollback
      showError('Failed to update');
    } finally {
      setIsPending(false);
    }
  };
  
  return (
    <button onClick={handleToggle} disabled={isPending}>
      {liked ? '❤️' : '🤍'}
    </button>
  );
}

The button updates instantly. If the API call fails, the state rolls back.

The Real Principle

Stop thinking about these as loading patterns and start thinking about honest communication. Use skeleton screens when the layout is predictable and content-heavy. Use spinners when you're genuinely uncertain. Use optimistic UI when you control the outcome. Mix them—most modern apps use all three in different places.

The best loading experience is the one that matches reality and respects the user's time.

Share
LP

LavaPi Team

Digital Engineering Company

All articles