Loading states beyond the spinner

Skeletons, optimistic UI, progress bars. The four loading patterns worth shipping, and the latency thresholds that decide which one.

A spinner is what you ship when you've stopped thinking. It's the default, it's universal, and it's the wrong choice for almost every loading state in a modern product. Users read a spinner as the app is doing something I don't understand for a length of time I can't predict, and that's about the worst message you can send while their attention is still on the screen.

Four loading patterns side by side: skeleton rows, optimistic toggle, progress bar with steps, and Suspense fallback shell.
Four loading patterns. Each one solves a different problem.

The fix isn't a better spinner. It's understanding that loading is not one problem, it's four, and each one wants a different shape.

The four loading patterns

1. Skeleton screens

Grey placeholder shapes that match the structure of the content about to load. The user sees the layout immediately and the content fills in row by row. Facebook popularized them; LinkedIn, YouTube, and every modern dashboard ship them now.

A list of skeleton rows on a dark dashboard, each row showing a faint shimmer sweeping left to right.
Skeleton rows preserve layout and read as alive, not broken.

Use skeletons when content has a stable, predictable shape, a list of rows, a card grid, a profile page. Don't use them for free-form content where the skeleton would have to guess (a chat thread, a search result page where row count is unknown).

2. Optimistic UI

Show the result of the action as if it succeeded, then reconcile with the server response. Click "like" and the heart fills immediately; the API call happens in the background. Linear, Notion, and every chat app converge on this for any action with a < 5% failure rate.

Reach for it when the action is almost certain to succeed and the cost of a rare reversal is low. Avoid for actions with real failure modes (payment, delete, publish), where a confident success state followed by an error is worse than a brief wait.

3. Progress bars

A horizontal bar that fills as a known-duration operation completes. Use them when you can actually estimate progress, file uploads, multi-step exports, video processing. Determinate with a percentage when you can compute it, indeterminate (the moving stripe) only when you genuinely can't.

The mistake is using a progress bar as decoration on a 400ms request. The bar fills, jumps, and disappears before the user reads it. Below ~2 seconds, ship a skeleton or nothing at all.

4. Suspense fallbacks and deferred loads

Render the parts of the page that are ready, defer the parts that aren't. React Suspense, Next.js loading.tsx, and similar primitives make this the default in modern frameworks. The user sees the shell and navigation while the slow data renders later.

This is the right pattern for any page that has a fast shell and a slow inner component, an analytics dashboard with a heavy chart, a settings page with a billing widget that fetches from Stripe.

The latency thresholds that decide

Loading UX is mostly a function of how long the wait actually is. These rough thresholds decide which pattern earns its slot.

  1. 01Under 100ms. Feels instant. Ship nothing, no spinner, no skeleton. Anything you add here makes the experience feel slower than the silence.
  2. 02100ms to 1 second. The user notices a small delay but doesn't lose focus. Optimistic UI wins; if you can't fake it, a tiny inline spinner on the affected element is fine.
  3. 031 to 3 seconds. The user is now waiting. Show a skeleton or a partial render. A page-wide spinner at this duration is the worst-case experience.
  4. 043 to 10 seconds. The user will leave if you don't communicate. Progress bar with current step ("Uploading 2 of 5 files"), or skeleton with a small status label.
  5. 05Over 10 seconds. Don't make the user wait synchronously. Move the operation to the background and notify them when it's done; offer a way to leave the screen and come back.

Seven rules that make loading feel fast

  • Animate skeletons subtly. A gentle shimmer reads as alive. A static grey block reads as broken.
  • Reserve space exactly. If the loading skeleton is 64px tall and the loaded content is 80px, the page jumps. Layout shift is the loudest loading bug.
  • Cache aggressively. The fastest loading state is no loading state. Use SWR, React Query, or your framework's revalidation primitives to serve stale data instantly and refresh in the background.
  • Show stale content with a refresh hint instead of a spinner over the old data. Users can keep reading while you fetch.
  • Stagger skeleton reveals for long lists. Don't render 50 skeleton rows at once; render the first 8 and let the rest stream in.
  • Disable submit buttons during in-flight, not afterward. A button that re-enables after success feels responsive; one that stays disabled feels stuck.
  • Never show a loading state for less than 250ms. Below that, the flash of skeleton appearing and disappearing is more jarring than the wait it covered. Add a small delay before the loading affordance renders.

Patterns that backfire

  1. 01The full-page spinner overlay. It blocks the entire UI for a partial update. Use a skeleton or inline indicator instead.
  2. 02The percentage that lies. A progress bar that hits 95% in two seconds and then sits there for ten kills trust faster than no bar at all. Either report real progress or use indeterminate animation.
  3. 03The loading message that judges. "Almost there!" at 3 seconds is fine. "Please be patient" is never fine.
  4. 04The spinner inside the button. If the button's job is to submit, the loading affordance is the disabled state. Stacking a spinner inside it doubles the noise without adding signal.

How to audit your loading states this week

Open your product. Throttle your network to Slow 3G in DevTools. Click through the ten most common flows. For each loading state you see, write down: how long it lasted, what affordance appeared, and whether the affordance matched the latency. Most teams find half their spinners are firing on operations that don't need them, and half their long operations have no feedback at all. That gap is the work.

For deeper background, the Nielsen Norman Group's response time research still holds up; it's where the 100ms / 1s / 10s thresholds come from. Web.dev's Core Web Vitals guide is the reference for layout-shift rules.

Frequently asked

When should I use a skeleton instead of a spinner?

Almost always, if the loading region has a stable shape. Skeletons preserve layout, communicate structure, and feel faster than a centered spinner over an empty area. Reserve spinners for tiny inline operations (a button submit, a small async toggle) where a skeleton would be overkill.

How long is too long before showing feedback?

Anything past about 250ms should show feedback; anything below that shouldn't. The trick is to delay the loading affordance by ~200ms so it only appears for operations that genuinely need it, otherwise the flash of skeleton on fast operations is its own kind of jank.

Should I use optimistic UI for everything?

No. Optimistic UI is for actions that almost always succeed and whose failure is cheap to reverse, likes, toggles, list reorders. For payments, deletes, or anything where the user would be surprised by a rollback, wait for the server and show a clear loading state.

What about loading on slow networks?

Design for the worst case and the best case takes care of itself. Throttle to Slow 3G in DevTools and audit your top flows. If your product is unusable on Slow 3G, you're shipping a product for one demographic and writing off the rest.

Ship one

The interaction entry in the directory ships tuned prompts for skeleton screens, optimistic toggles, and progress bars with the type scale and motion curves already encoded. Pair with the forms and empty states entries, the three are sibling problems and the same system handles all of them.

Keep reading