All articles
Design & UXAugust 6, 2026 6 min read

Skeleton Screens vs Spinners vs Nothing: Picking a Loading Pattern That Doesn't Lie

Loading states are where trust is won or lost. Skeletons, spinners, and doing nothing at all each have a job — most teams pick wrong and pay for it in bounce rate.

Every product has three or four screens where users decide whether to trust you. Almost all of them involve waiting. And most teams default to a spinner because it's the path of least resistance — which is exactly why their loading states feel cheap.

Loading UI is a design decision disguised as a technical one. Here's how we think about it when we ship.

The Real Question Isn't "Which Loader"

Before picking a pattern, answer three questions:

  1. How long will this actually take? Not worst-case. P50 and P95.
  2. Does the user know what's coming? Repeat visit to a dashboard vs first-time search results are different problems.
  3. Is the layout predictable? A product card grid is; an AI response isn't.

Those three answers pick the pattern for you. Everything else is decoration.

The timing thresholds that matter

There's a well-worn set of perception thresholds from Nielsen's group and decades of HCI research that still hold up. Roughly:

  • Under ~100ms: feels instant. Show nothing. A loader here is worse than no loader.
  • 100ms – 1s: noticeable but tolerable. Optimistic UI or a subtle state change beats a spinner.
  • 1s – 4s: user is waiting. This is skeleton or determinate progress territory.
  • Over ~4s: you owe them a progress indicator with real information, or an escape hatch.

If your loader flashes for 200ms and disappears, you've made the UI feel worse than if you'd shown nothing. That flash is a jank signal.

Pattern 1: Show Nothing (The Underrated Default)

For anything under ~150ms, the correct pattern is no pattern. Just wait, then render.

The trick is enforcing this. If your data layer resolves in 80ms locally but 400ms on a slow 4G connection, you need a delay wrapper that decides whether to show the loader at all.

function useDelayedLoading(isLoading: boolean, delay = 150) {
  const [show, setShow] = useState(false);

  useEffect(() => {
    if (!isLoading) {
      setShow(false);
      return;
    }
    const t = setTimeout(() => setShow(true), delay);
    return () => clearTimeout(t);
  }, [isLoading, delay]);

  return show;
}

Wrap your skeleton in this and the fast case renders without a flash. The slow case still gets feedback. Cheap, obvious, rarely done.

Pattern 2: Skeleton Screens

Skeletons work when three conditions are true:

  • Layout is predictable (you know the shape of what's coming).
  • Load time is 1 – 4 seconds in the real world.
  • The content is primary — the reason the user is on this screen.

A product listing? Skeleton. A user profile header? Skeleton. A modal that might contain form fields, might contain a chart? Not a skeleton.

Skeletons that don't lie

The most common skeleton mistake is drawing a fake UI that doesn't match what loads. Three lines of grey become one giant heading. Six card placeholders become two. Users notice this even if they can't articulate it — the layout shift breaks the illusion and you've paid the animation cost for nothing.

Rules we follow:

  • Match the real dimensions within ~10%. If the average card is 320px tall, the skeleton is 320px tall.
  • Match the count you expect, not a decorative number. If the API returns 12 items, show 12 skeletons, not 3.
  • Skip decoration. Avatars, icons, and secondary metadata don't need skeleton placeholders. Grey out only what's structurally load-bearing.
  • Shimmer is optional and often wrong. A static muted block reads as "loading" perfectly well and costs zero CPU. Save shimmer for hero content.
.skeleton {
  background: hsl(220 13% 91%);
  border-radius: 6px;
}

@media (prefers-reduced-motion: no-preference) {
  .skeleton {
    background: linear-gradient(
      90deg,
      hsl(220 13% 91%) 0%,
      hsl(220 13% 95%) 50%,
      hsl(220 13% 91%) 100%
    );
    background-size: 200% 100%;
    animation: shimmer 1.4s ease-in-out infinite;
  }
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

Note the prefers-reduced-motion guard. Shimmering rectangles across a full page trigger vestibular discomfort for some users. Static skeletons are the accessible default; motion is the enhancement.

Pattern 3: Spinners (And Why They're Usually Wrong)

Spinners have one honest use: indeterminate work of unknown duration on a small surface, like a button after submit or a save indicator.

When a spinner replaces a whole screen, it says: "I have no idea what's happening or how long it'll take, and neither will you." That's fine for a 300ms button state. It's a failure mode for a 3-second data load.

If you find yourself reaching for a full-page spinner, ask:

  • Can I predict the layout? → Use a skeleton.
  • Can I predict the duration? → Use a progress bar.
  • Is the user blocked from doing anything else? → Rethink the flow.

The one place we still like spinners: inline within a control the user just interacted with. Submit buttons, refresh icons, autocomplete inputs. The spinner is scoped to the thing they touched, which makes causality obvious.

Pattern 4: Determinate Progress

Any operation that reliably takes more than 4 seconds — file uploads, exports, AI generations with predictable token counts, multi-step migrations — should show real progress.

Fake progress bars (the kind that ease from 0 to 90% and hang) actively damage trust. Users have been burned by these for two decades. They know.

If you can't measure real progress, at least surface stage: "Uploading… Processing… Finalizing." Named stages give users a mental model even without a percentage.

For streaming responses

AI-heavy products have a new pattern: the response streams token by token. The loading state is the content appearing. If you're building a chat or generation UI, skip the skeleton entirely and stream. A skeleton followed by streamed text is two loading states back to back, which reads as broken.

Just make sure the first token arrives fast. Time-to-first-token under ~800ms is the difference between "thinking" and "stuck."

Accessibility: The Part Everyone Forgets

Screen readers don't see your beautiful shimmer. They need to be told what's happening.

  • Wrap loading regions in aria-busy="true" while loading, remove when done.
  • Use role="status" with aria-live="polite" for a hidden text announcement: "Loading products."
  • On completion, announce the result: "12 products loaded." Don't spam this on every re-fetch — debounce or only announce on significant state changes.
  • Skeletons themselves should have aria-hidden="true" — they're visual scaffolding, not content.
<section aria-busy={isLoading} aria-live="polite">
  {isLoading ? (
    <div aria-hidden="true"><ProductSkeletons count={12} /></div>
  ) : (
    <ProductGrid items={items} />
  )}
  <span className="sr-only">
    {isLoading ? 'Loading products' : `${items.length} products loaded`}
  </span>
</section>

This is a five-minute change that materially improves the experience for a real segment of your users. Do it once in your data-fetching wrapper and every screen inherits it.

The Decision Table We Actually Use

SituationPattern
Cached/optimistic, <150msNothing (with delay guard)
Predictable layout, 150ms–4sSkeleton, matched to real shape
Small inline actionScoped spinner on the control
Unpredictable layout, 1–4sNeutral loading block + label
Long operation, measurableDeterminate progress
Long operation, unmeasurableNamed stages
AI/streamingStream content directly

The pattern is downstream of the situation. Pick the situation right and the loader picks itself.

Where We'd Start

If you're auditing an existing product, do this in an afternoon:

  1. Open your slowest three screens on throttled 4G in devtools. Time each loading state.
  2. Anything flashing under 150ms — add a delay guard.
  3. Anything spinning between 1 and 4 seconds on a predictable layout — replace with a matched skeleton.
  4. Anything spinning over 4 seconds — add stages or real progress, and an escape hatch.
  5. Add aria-busy and a polite live region to your fetch wrapper. Ship once, benefit everywhere.

Loading states are the connective tissue between every interaction and every result. Treat them like the design surface they are, not like a fallback you throw in at the end. If you want a hand auditing yours, we do this kind of work as part of our product design engagements.

#UX#Performance#Design Systems#Accessibility#React

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project