Skeleton Screens vs Spinners vs Nothing: Picking the Right Loading Pattern
Skeletons feel modern, spinners feel dated, and sometimes showing nothing is the fastest option. Here's how we decide between them on real product work.

Skeleton screens became the default loading pattern somewhere around 2018, and a lot of teams have been reflexively shipping them ever since. But a skeleton is not always the right answer — sometimes a spinner is honest, sometimes a progress bar is required, and sometimes the fastest UX is to show nothing at all and let the content pop in.
This is the framework we use when a designer and a frontend engineer are staring at a Figma file arguing about it.
The four options, honestly compared
There are really only four loading patterns worth considering for most product surfaces. Everything else is a variation.
- Nothing — no loader, no placeholder. The UI paints when data arrives.
- Spinner — an indeterminate animated indicator. Cheap, universal, feels dated.
- Skeleton screen — grey blocks shaped like the eventual content, usually shimmering.
- Progress bar — determinate, tied to a known percentage or step count.
Each one signals something different to the user, and picking the wrong one is a real UX bug — not a nitpick.
What each pattern actually communicates
A spinner says: something is happening, I don't know how long. A skeleton says: content is coming, and here's roughly what it will look like. A progress bar says: I know exactly how far along we are. Showing nothing says: this is instant.
If you show a spinner for a 120ms request, you're lying about your own speed. If you show a skeleton for a 4-second upload, you're withholding information the user needs.
The timing thresholds that matter
The Nielsen Norman numbers are old but still hold up in our experience. Rough thresholds we use:
- Under ~100ms: perceived as instant. Show nothing.
- 100 – 400ms: perceived as a slight delay. Still, show nothing — a loader that flashes in and out looks worse than a small pause.
- 400ms – 1s: users notice. Show a skeleton or a spinner.
- 1s – 10s: users are waiting. Skeleton for content, progress bar if you know the percentage.
- Over 10s: users will context-switch. Progress bar plus a status message, ideally with an option to cancel or continue in background.
The common mistake is showing a loader immediately. That produces the dreaded "flash of loading state" where a skeleton appears for 80ms and then swaps to real content. It feels janky even though technically nothing is wrong.
The delayed-loader pattern
We usually gate loaders behind a short delay. Something like this in a React hook:
import { useEffect, useState } from 'react';
export function useDelayedFlag(active: boolean, delay = 300) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!active) {
setVisible(false);
return;
}
const t = setTimeout(() => setVisible(true), delay);
return () => clearTimeout(t);
}, [active, delay]);
return visible;
}
// usage
const isLoading = useQuery(...).isLoading;
const showSkeleton = useDelayedFlag(isLoading, 300);
That 300ms window swallows the vast majority of fast responses. When the network is genuinely slow, the skeleton appears and communicates properly. When it's fast, users see the content directly with no flicker.
Pair it with a minimum visible time if you want to avoid the opposite problem — a skeleton that appears and disappears in 50ms because the request happened to finish just after the delay elapsed.
When to use each pattern
Here's how we actually decide, surface by surface.
Use a skeleton when
- The layout of the incoming content is predictable and stable.
- The response usually takes 400ms to a few seconds.
- The page is the primary content (dashboard, feed, article, product listing).
- You want to reduce perceived load time — skeletons consistently test as feeling faster than spinners for content-heavy screens.
The key word is predictable. If your skeleton shows three cards and the response returns seven, or shows a title bar that's the wrong height, the layout shift on hydration is worse than no skeleton at all. Skeletons only work when they match the final layout closely.
Use a spinner when
- The action is user-initiated and localised — a button, a form submit, a small widget.
- The result won't have a predictable shape (a toast, a modal, a redirect).
- You need a lightweight indicator inside a small component.
Spinners get a bad rap but they're still the correct choice for button loading states, inline actions, and anywhere the outcome isn't a big content block. A spinner inside a submit button is honest and clear. A skeleton there would be weird.
Use a progress bar when
- You genuinely know the progress (file uploads, multi-step imports, video processing).
- The operation takes more than a couple of seconds.
- Users need reassurance that it hasn't stalled.
Do not fake progress bars. If you don't know the actual percentage, use a spinner or an indeterminate bar. Fake progress bars that jump to 90% and then hang there erode trust fast.
Use nothing when
- The operation is under ~200ms.
- The content is above the fold and rendered server-side.
- The transition is a small state change that doesn't warrant an indicator.
This is the pattern most teams under-use. If your API is fast and you're using something like React Server Components, streaming SSR, or a well-cached edge response, you often don't need any loading state at all. The absence of a loader is itself communication: this was instant.
Common mistakes we still see in 2026
Skeletons that don't match the final layout
A skeleton with three grey rectangles that resolves into a table with sorting controls, filters, and pagination is worse than useless — it causes a bigger perceived shift than no skeleton. The rule: if your skeleton doesn't share the same bounding box as the loaded state, don't ship it.
Full-page spinners for partial updates
When a user changes a filter on a product list, you don't need to blank the whole page and show a spinner. Dim the list, keep the filters interactive, and swap in new items when they arrive. This is where optimistic UI and stale-while-revalidate patterns earn their keep.
Ignoring prefers-reduced-motion
Shimmer effects on skeletons are motion. Respect the user's preference:
.skeleton {
background: linear-gradient(90deg, #eee, #f5f5f5, #eee);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
background: #eee;
}
}
A static grey block still communicates "loading" to users with vestibular sensitivities. The shimmer is decoration, not information.
Missing accessibility hooks
Sighted users see the skeleton. Screen reader users need an equivalent signal:
<div role="status" aria-live="polite" aria-busy="true">
<span class="sr-only">Loading products</span>
<div class="skeleton" aria-hidden="true"></div>
</div>
aria-busy on the container and a visually hidden status message do the work. When the real content arrives, drop aria-busy and remove the status.
A quick decision tree
When you're not sure which to use:
- Is the operation under 200ms in the 75th percentile? → Nothing.
- Is it a button or small inline action? → Spinner.
- Do you know the actual progress? → Progress bar.
- Is the loading area a stable, predictable content block? → Skeleton with a 300ms delay.
- Otherwise → Delayed spinner.
Run this against every loading state in your app and you'll likely find a third of them are wrong.
Where we'd start
Audit your app for loader-flashing first — it's the highest-impact, lowest-effort fix. Add a delayed-loader hook to your data-fetching layer, set the threshold at 300ms, and watch how many spinners and skeletons quietly disappear from your fast paths. Then look at your slowest three screens and check whether the skeletons there actually match the final layout. If they don't, either fix the skeleton or replace it with a spinner and a status message. Loading states are one of the cheapest places to make an app feel dramatically better without touching a single backend query — but only if you treat the pattern choice as a real design decision instead of a default.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Form Errors Should Move Toward the User, Not Away: Inline Validation That Actually Helps
Most inline validation is punishment dressed up as UX. Here's how to design and build form errors that reduce abandonment instead of causing it.
Toast Notifications Are Broken: A Better Pattern for Async Feedback
Toasts get dismissed before anyone reads them, stack into unreadable columns, and vanish for screen reader users. Here's the pattern we use instead when work happens off the main thread.
Design Tokens That Survive Contact With Engineering
Most design token systems die the moment they hit a real codebase. Here's how we structure tokens so they actually get used — and stay in sync between Figma, Tailwind, and native.
