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
typescriptfunction 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.
typescriptfunction 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
typescriptfunction 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.
LavaPi Team
Digital Engineering Company