Skeleton Screens Are Lying to Your Users: A Better Loading Strategy
Skeleton screens became the default for perceived performance, but most implementations make apps feel slower and less trustworthy. Here's when to use them, when to skip them, and what to build instead.

Skeleton screens were supposed to fix perceived performance. Somewhere between the Facebook redesign and every dashboard template on GitHub, they became the default — the reflex. Ship a loading state? Slap on a shimmer. But if you actually watch users interact with skeleton-heavy apps, you'll notice something uncomfortable: they don't feel faster. They feel like they're pretending.
The original argument, and where it broke
The case for skeleton screens was reasonable. A 2013 Luke Wroblewski post popularised the idea, and the logic went: showing the shape of content while it loads reduces perceived wait time, because the user's brain starts parsing layout before data arrives. Spinners, by contrast, focus attention on the wait itself.
That argument still holds — under specific conditions. The problem is the conditions got ignored.
Skeletons work when:
- The final layout is predictable (you know roughly what shape the content will be).
- The load time is in the 300ms to 2s range.
- The content is the primary focus of the screen.
Skeletons fail when the layout is dynamic, when loads are near-instant, when loads are long, or when the skeleton itself becomes the design. And that last case is where most teams are today.
The shimmer tax
In our experience auditing client apps, a shimmer animation that runs across an entire dashboard for 400ms adds cognitive load rather than reducing it. The eye tracks the movement, expects resolution, and gets a hard content pop instead. Users report the app feels "glitchy" without being able to say why.
Worse: skeleton screens that don't match the final layout cause micro-reflows. A three-line placeholder resolves into a five-line paragraph, the button moves, and the user's tap lands somewhere unintended. This isn't hypothetical — it's the same class of problem as CLS, just self-inflicted.
A decision tree for loading states
Before reaching for a skeleton, ask what you actually know about the load. Here's the framework we use on projects:
Expected load time?
├── < 200ms → Show nothing. Render when ready.
├── 200ms – 1s → Skeleton if layout is stable, else subtle fade-in.
├── 1s – 4s → Skeleton + progressive reveal, OR optimistic UI.
└── > 4s → Progress indicator with context ("Analyzing 2,341 rows...")
The under-200ms case is the one teams get wrong most often. A skeleton that flashes for 120ms is worse than no skeleton at all — it's a strobe. Wrap loads in a delay:
function DelayedSkeleton({ delay = 200, children }) {
const [show, setShow] = useState(false);
useEffect(() => {
const t = setTimeout(() => setShow(true), delay);
return () => clearTimeout(t);
}, [delay]);
return show ? children : null;
}
Combine this with a minimum display time (say 300ms) once the skeleton does appear, so it doesn't flash-and-vanish when the data lands 20ms later. Yes, this makes fast loads feel marginally slower. That's the point — consistency beats raw speed for perceived quality.
Match the skeleton to the payload, not the layout
Here's the mistake we see most often: skeletons that mirror the maximum possible content. Six card placeholders when the response might return two. Full-width text bars when the actual text is a single word.
Better approach: skeletons should reflect the expected shape based on what you already know. If you're paginating and the user is on page 2, you know exactly how many items to expect. If you have a cached count, use it. If the item has a fixed height, match it precisely.
Content-aware placeholders
For list views, this means:
function ItemListSkeleton({ count }: { count: number }) {
return (
<ul className="space-y-2" aria-busy="true" aria-live="polite">
{Array.from({ length: count }).map((_, i) => (
<li
key={i}
className="h-16 rounded-md bg-neutral-100 dark:bg-neutral-800"
style={{ opacity: 1 - i * 0.08 }}
/>
))}
</ul>
);
}
Note two things: no shimmer animation, and a subtle opacity fade down the list. The fade signals "this is temporary state" without introducing motion that competes with the eventual content. And aria-busy plus aria-live gives screen readers something to work with — a detail almost every skeleton implementation skips.
Optimistic UI is usually the better answer
For any action the user initiates — submitting a form, liking a post, adding to cart — skeletons are the wrong tool entirely. The user knows what they just did. Show them the result immediately and reconcile if the server disagrees.
We've rebuilt several e-commerce flows around optimistic mutations and consistently seen the same pattern: bounce rates on the cart step drop, and support tickets about "the site froze" disappear. The load didn't get faster; the feeling of control got better.
A sketch with TanStack Query:
const mutation = useMutation({
mutationFn: addToCart,
onMutate: async (item) => {
await queryClient.cancelQueries({ queryKey: ['cart'] });
const previous = queryClient.getQueryData(['cart']);
queryClient.setQueryData(['cart'], (old) => [...old, item]);
return { previous };
},
onError: (_err, _item, context) => {
queryClient.setQueryData(['cart'], context.previous);
toast.error('Could not add to cart — try again');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['cart'] });
},
});
The UI updates instantly. If the request fails, the state rolls back and the user gets a clear error. No skeleton, no spinner on the button, no perceived latency.
When optimism goes wrong
Optimistic UI is not a fit for actions where failure is common or the consequences of showing wrong state are high — payment confirmations, publish-to-production toggles, anything involving money moving between accounts. In those cases, a button-level loading state ("Processing...") is honest and appropriate. Save skeletons and optimism for the low-stakes 90%.
Progressive reveal beats binary loading
The modern React server components model — and Suspense boundaries more generally — makes it easy to load a page in tiers. The mistake is treating the whole page as one boundary. Better: identify what's expensive and isolate it.
A product page might load:
- Instantly (from cache or static): shell, nav, product title, hero image URL.
- Fast (< 300ms, single query): price, stock status, primary CTA.
- Slower (300ms – 1s): reviews, related items, personalisation.
Each tier gets its own Suspense boundary. The user sees the product name and CTA within a hundred milliseconds. They can start reading while the reviews section still shows a skeleton. The page never feels blocked, because it never actually is.
This is where skeletons genuinely shine — as localised placeholders for genuinely slow sub-sections, not as a full-page freeze frame.
Accessibility is not optional here
A loading state that only communicates visually is broken for anyone using assistive tech. Minimum bar:
- Wrap loading regions in
aria-busy="true". - Use
aria-live="polite"on the container so state changes are announced. - Provide a text label for the loading state — visually hidden is fine.
- Respect
prefers-reduced-motion: no shimmer, no pulse, static placeholder only.
@media (prefers-reduced-motion: reduce) {
.skeleton-shimmer {
animation: none;
background: var(--color-neutral-100);
}
}
Most design system skeleton components we've audited fail at least two of these. It's a five-minute fix that meaningfully improves the experience for a nontrivial slice of users.
Measuring whether it actually helped
Gut feel is not enough. If you're going to invest in loading states, measure them:
- Time to first meaningful paint — cheap and standard.
- Interaction to Next Paint (INP) — did the loading state block input?
- Session-level bounce on slow loads — filter your analytics by connection speed and compare.
- Qualitative: 5 users, think-aloud — you'll learn more in an afternoon than a month of dashboards.
If you rip out a full-page skeleton and replace it with progressive reveal and none of these move, you had a solved problem and wasted your time. That's fine — knowing is the point.
Where we'd start
Open your app and count the skeleton screens. For each one, ask: is this load actually > 200ms? Does the placeholder match the real content? Is there a user action I could make optimistic instead? Nine times out of ten, one of those questions produces a better answer than the shimmer you shipped two years ago.
If you want a second pair of eyes on your loading strategy — or a broader UX audit — that's the kind of work our design and engineering team does regularly. The wins are usually smaller than a redesign and bigger than they have any right to be.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Design Tokens That Survive Contact With Engineering: A Naming Convention That Scales
Most design token systems die at the second theme. Here's a three-tier naming convention that survives dark mode, brand refreshes, and the inevitable Tailwind migration.

Empty States Are Your Second Homepage: Designing Them Like Product Surface
Empty states get treated like an afterthought — a shrug icon and a sentence. They're actually one of the highest-leverage screens in your product. Here's how to design and ship them like it.

Focus Rings Are a Product Decision: Designing Keyboard Focus That Ships
Most focus rings are either invisible or ugly enough that a designer strips them out. Here's how to design keyboard focus that survives brand review, passes WCAG, and doesn't break your dark mode.
