DEV Community

RAXXO Studios
RAXXO Studios

Posted on • Originally published at raxxo.shop

Loading Skeletons That Don't Lie: 5 Patterns for Honest Perceived Performance

  • Content-shaped skeletons cut layout shift to zero versus 0.18 CLS for spinners

  • Match placeholder dimensions to final DOM exactly or you are lying to users

  • Spinners beat skeletons under 300ms and for unknown-shape content

  • Streaming SSR makes the skeleton honest about what is actually arriving

I rebuilt the loading states on my storefront and watched cumulative layout shift drop from 0.18 to 0.00 on the product grid. The fix was not faster code. It was honest placeholders that matched the final layout pixel for pixel. Here is what actually worked.

A Skeleton Lies When It Does Not Match the Final Layout

Most skeleton screens are decorative. They show three grey bars where the real content has two lines and an image, then the page snaps into a totally different shape when data lands. That snap is a layout shift, and it is the single most annoying thing a loading state can do. The user reads the skeleton, builds a mental model, and then the model is wrong.

The rule I follow now: a skeleton placeholder must occupy the exact same box as the content it replaces. Same height, same width, same number of lines, same image aspect ratio. If the product card is 320 pixels tall with a 1:1 image and two text lines, the skeleton is 320 pixels tall with a 1:1 grey block and two grey lines. No more, no less.

This means I stopped building generic skeleton components. A reusable `that renders "three bars" is a trap because it never matches anything. Instead I build a skeleton per layout. The product card has aProductCardSkeleton. The article header has anArticleHeaderSkeleton`. Each one is the real component with the data swapped for grey shapes, sharing the same CSS grid and the same fixed dimensions.

Here is the measurement that convinced me. Before, my product grid scored 0.18 CLS in Lighthouse, mostly from images loading without reserved space and text reflowing. After matching skeleton boxes to final boxes and reserving image dimensions with aspect-ratio, CLS hit 0.00 on three test pages in a row. The grid does not move when data arrives. It just fills in.

The honesty principle goes deeper than dimensions. If you show four skeleton cards but the response returns six items, you taught the user to expect four. Pull the skeleton count from the same source as the real count when you can, like a cached previous page length or a known page size. A skeleton that promises four and delivers six is a small lie, and small lies in UI add up to distrust.

Match the Box: aspect-ratio and Reserved Space Beat Spinners

The mechanical part of zero layout shift is reserving space before anything loads. Images are the worst offenders. An `` with no width and height collapses to zero height, then jumps to full height when the file decodes. The browser cannot reserve space it does not know about.

Two lines fix most of this. Set aspect-ratio on the image container and width: 100% on the image:


.card-image {
  aspect-ratio: 1 / 1;
  width: 100%;
}

Enter fullscreen mode Exit fullscreen mode

Now the box holds its shape from first paint. The skeleton fills that box with a grey block of identical ratio, and when the real image decodes it slots in without moving a pixel. I do the same for text blocks using fixed line counts and min-height so a two-line title placeholder cannot become a three-line title.

The shimmer animation is where people go wrong. A diagonal gradient sweeping across the grey is fine, but it must respect motion preferences. I gate every shimmer behind a media query:


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

Enter fullscreen mode Exit fullscreen mode

Without that, you are pushing a moving gradient at people who explicitly asked their operating system for less motion. That is an accessibility failure, and it is two lines to fix.

There is also a performance cost to shimmer that nobody talks about. Animating background-position on a hundred skeleton elements forces repaints. On a low-end Android device my old shimmer dropped the loading state to 22 frames per second. Switching to a single CSS variable driving one keyframe, and animating opacity on a pseudo-element instead of background position, brought it back to 60. The loading state should not be the slowest part of the page.

I store the skeleton dimensions in the same place as the layout tokens. The card height, the image ratio, the gap, all live as CSS custom properties that both the skeleton and the real card read. When I change the card height, both update together. They cannot drift apart because they share one source. If you want the deeper context on reserving storefront layout early, see Shopify Section Rendering API.

When a Spinner Actually Beats a Skeleton

Skeletons are not universally better. I see teams force skeletons onto everything because they read one article calling spinners outdated. That is wrong. A spinner wins in three concrete situations.

First, sub-300ms loads. If your data comes back in 180ms, a skeleton flashes for a tenth of a second and the flash itself reads as a glitch. Below roughly 300ms, showing nothing is better than showing a skeleton that vanishes before the eye registers it. I delay loading states by 200ms so fast responses never show a placeholder at all. The content just appears, which is the best possible outcome.

Second, unknown-shape content. A skeleton only works when you know the layout in advance. If the response could be a list, a single card, an error, or an empty state, you cannot draw a matching placeholder. Drawing a guess means you will almost certainly draw a lie. A centered spinner makes no claim about shape, so it cannot mislead. For search results where I do not know if zero or fifty items come back, I use a spinner, then render the real layout once I know the count.

Third, full-page transitions and actions like "saving" or "processing payment". Nobody expects a skeleton of a button they just clicked. They expect feedback that the system heard them. A small inline spinner on the button is the honest signal. A skeleton there would be absurd.

The decision tree I use is simple. Do I know the exact final layout? If no, spinner. Is the load likely under 300ms? If yes, delay then show nothing or spinner. Is it a known content shape over 300ms? Skeleton. Is it a discrete action with no layout? Inline spinner.

The mistake is treating the choice as fashion. It is an information question. A loading state communicates "what is coming and roughly how big". A skeleton answers that precisely. A spinner answers "something is coming, shape unknown". Pick the one that tells the truth about what you actually know. When you genuinely do not know the shape, pretending you do is the lie.

Streaming SSR Makes the Skeleton Honest About Timing

The most honest skeleton is one that disappears section by section as real data streams in, not one big swap at the end. Streaming server-side rendering makes this possible. The server sends the page shell immediately, holds open the connection, and flushes each slow section as its data resolves.

In practice the fast parts of my page (header, navigation, static product details) render instantly with real content. The slow part (personalized recommendations that hit a separate service) shows a skeleton until its data arrives, then streams in and replaces only that region. The skeleton is honest because it covers exactly the part that is genuinely still loading, not the whole page that already finished.

React Server Components with Suspense boundaries handle this cleanly. You wrap the slow component in }> and the framework streams the fallback first, then the real markup. The key detail most people miss: the fallback skeleton must match the resolved component dimensions, or the stream-in causes a layout shift and you have undone all your CLS work. Honesty and zero shift are the same goal.

I measured the difference on a product page. Without streaming, time to first contentful paint was 1.4 seconds because the whole page waited on the recommendation service. With streaming, FCP dropped to 0.3 seconds because the shell and main product rendered immediately, and only the recommendation strip showed a skeleton for the remaining 1.1 seconds. The user reads the product while the slow part loads behind a placeholder.

This is where skeletons earn their reputation. A skeleton that covers a section the server has already finished rendering is theater. A skeleton that covers the one section still waiting on a slow upstream call is an accurate status report. Streaming lets you draw the line in exactly the right place.

One caveat: streaming adds complexity to error handling. If the recommendation service times out, the skeleton must resolve into an error or empty state, never spin forever. I set a hard timeout and a fallback empty state so a stuck stream never leaves a skeleton shimmering into eternity. A skeleton with no exit is the worst lie of all, promising content that will never come.

Bottom Line

Honest loading states come down to one idea: never show a placeholder that contradicts what arrives. Match the box exactly so there is zero layout shift. Reserve image space with aspect-ratio. Gate shimmer behind prefers-reduced-motion. Use a spinner when you do not know the shape or the load is under 300ms, and a skeleton only when you know the layout in advance. Stream SSR so the skeleton covers the part that is genuinely still loading and nothing else.

I run all of this on a Shopify storefront, and the CLS drop from 0.18 to 0.00 was the most visible single win. The patterns are framework-agnostic though. Any stack with reserved dimensions and streamed sections can do the same.

If you want the system I use to keep these UI rules consistent across an entire site, the Claude Blueprint walks through how I document layout tokens and component contracts so skeletons and real content never drift apart. Start with one component, measure CLS before and after, and let the number prove it.

This article contains affiliate links. If you sign up through them, I may earn a small commission at no extra cost to you. (Ad)

Top comments (0)