Our services

If you can think it, we can make it brainsoft.

Loading states and skeletons that actually help

Written By: BrainSoft In Frontend

Most loading states get added at the end of a project, almost as an afterthought. Someone notices the table flashes empty for a moment, so a spinner goes in. Later a designer drops in a skeleton screen that looks nothing like the real content, and now the page jumps twice: once when the skeleton appears, once when the data lands. Users do not complain about this directly. They just feel the app is jittery and slow, even when the API is fast.

A good loading state does one job: it tells the user what is coming and roughly where it will be, without moving things around. Below I go through the decisions that matter, the timing thresholds I use, and the cases where the honest answer is to show nothing at all.

Match the skeleton to the real layout

A skeleton is a placeholder for content that has a known shape. If you know the row is an avatar, a name, and a timestamp, draw those three boxes in the right places with the right sizes. If you do not know the shape, a skeleton is a lie and you should not use one.

The test is simple: render the skeleton, screenshot it, then render the loaded state and screenshot that. Overlay them. If elements shift, the skeleton is wrong. This is the same layout shift metric you already track, just measured manually during development. Fixing it usually means giving the container a fixed or min height and using the same spacing scale as the real component.

.card-skeleton {
  min-height: 96px; /* same as a loaded card */
  display: grid;
  gap: 0.5rem;
}
.card-skeleton .line {
  height: 1rem;
  border-radius: 4px;
  background: #e5e7eb;
}

Keep the animation subtle. A slow pulse or a soft shimmer reads as "working". A fast strobe reads as "broken". Respect the user's motion preference:

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

Decide when to show anything at all

Flashing a spinner for 80ms is worse than showing nothing. The eye catches the flicker, and the interface feels unstable. I use a small set of thresholds and stick to them:

  • Under about 100ms: render nothing. The data is effectively instant.
  • 100ms to 1s: a lightweight indicator that does not change layout, such as a thin progress bar at the top or a subtle opacity change on the container.
  • Over 1s: a skeleton or spinner that occupies the space the content will fill.

The delay is easy to implement without extra libraries. Set a timer, and only flip the loading flag if it fires:

const [showSkeleton, setShowSkeleton] = useState(false);
useEffect(() => {
  const t = setTimeout(() => setShowSkeleton(true), 150);
  return () => clearTimeout(t);
}, []);

For a longer wait, do not leave the user staring at the same animation forever. After a few seconds, add a line of text such as "Still loading, this can take a moment" or a retry button. That turns a dead end into something actionable.

Make it accessible and honest

Screen readers need to know something is happening. A skeleton made of divs is invisible to them, so mark the region and its state:

<div aria-busy="true" aria-live="polite">
  <span class="sr-only">Loading results</span>
  <!-- skeleton rows -->
</div>

When the data arrives, drop aria-busy and move focus only if the user's action caused the change. Do not steal focus on background refreshes.

The other honesty issue is optimistic UI. If you show content before the server confirms it, you owe the user a visible failure path. A toast that disappears is not enough when a payment or a save is involved. Roll the state back and say what happened.

If you are weighing how much of this to build in house versus handling it as part of a larger frontend effort, our services cover the full stack, but the patterns above are cheap to apply on your own.

Know when not to use a skeleton

Skeletons are not a default. They are wrong for short waits, for content whose size varies wildly, and for anything the user is actively waiting to read line by line, like search results that stream in. In those cases a simple inline indicator or a partially rendered list is calmer.

They are also wrong when the data is already cached. If you can render from local state immediately and revalidate in the background, do that. The best loading state is the one the user never sees because the content was ready.

Finally, keep one loading pattern per context. If the profile page uses a skeleton and the settings page uses a centered spinner for the same kind of fetch, users have to relearn the interface on every screen. Consistency beats cleverness here.

Frequently asked questions

How long should a skeleton be shown before switching to an error state?

Tie it to your request timeout, not a fixed number. If the fetch is configured to abort after ten seconds, show the skeleton until then, then replace it with an error message and a retry action. Never leave a skeleton animating indefinitely with no outcome.

Should I use a spinner or a skeleton?

Use a skeleton when you know the shape of the content and the wait is over a second, because it reserves the space and prevents layout shift. Use a spinner for short, unpredictable actions like submitting a form, where a placeholder would be misleading.

Do skeletons actually make pages feel faster?

They change perceived speed, not real speed. A skeleton that matches the final layout keeps the page stable and gives the eye something to anchor on, which feels smoother. A mismatched skeleton that causes a jump often feels slower than a plain spinner.


#Frontend