Frontend Aug 10, 2026 · 5 min read

Loading states: that centered spinner is a product decision

Yohangel Ramos

Yohangel Ramos

Tech Lead · Senior Fullstack Developer

There is one screen almost nobody designs and every user sees: the one that shows up while the data loads. The Figma mockup arrives with the data already in place, pretty and complete, and the loading state gets resolved in the last commit with an if (loading) return <Spinner />. That if is a product decision made at eleven at night by whoever was in a hurry. And it shows: the app feels slow, the page jumps when the data lands, and the user cannot tell whether to wait or reload. I have spent years fixing this in dashboards, in a meal planner and in internal admin panels, and my conclusion is that loading states account for most of the speed your users think your product has.

The centered spinner is the worst possible default

A spinner in the middle of the screen says exactly one thing: "something is happening, I do not know what, I do not know how long". It wipes out the structure you already knew and replaces it with a wheel. When the data arrives, the interface appears all at once in a different position and your eye has to re-orient itself.

The underlying problem is that it treats the page as an atomic unit: either everything is there or nothing is. That is almost never true. In a typical dashboard, the title, the navigation, the filters and the shape of the cards are all known before you request a single byte from the server. The only missing part is the numbers. Blocking the 90% of the interface you could already paint in order to wait for the 10% you cannot is throwing away perceived performance.

The rule I follow: paint everything you already know, and mark as pending only what genuinely depends on the server.

Skeletons that do not shift: reserve the real space

A skeleton is a placeholder shaped like the final content. It works for two reasons: it communicates what is about to appear and, more importantly, it reserves the space so nothing jumps when it does. If your skeleton is 40px tall and the real row is 72px, you have traded a spinner for a layout shift, which is worse.

That is why I build them from the same component instead of a parallel mockup that drifts out of sync on day one:

function CandidateRow({ data }) {
  return (
    <li className="row">
      <span className="avatar">{data ? <img src={data.avatar} alt="" /> : null}</span>
      <span className="name">{data ? data.name : <Block w="60%" />}</span>
      <span className="title">{data ? data.title : <Block w="35%" />}</span>
    </li>
  );
}

The row keeps the same height and the same grid in both states, so going from skeleton to data is a content change, not a layout change. CSS does the heavy lifting:

.row { display: grid; grid-template-columns: 40px 1fr 1fr; min-height: 72px; }
.avatar { aspect-ratio: 1; border-radius: 50%; background: var(--gray-200); }

@media (prefers-reduced-motion: no-preference) {
  .block { animation: pulse 1.4s ease-in-out infinite; }
}

Two details that always get forgotten: min-height on the row (it prevents CLS when the real content is taller) and honoring prefers-reduced-motion, because a screen full of pulsing blocks is exactly the kind of animation that makes some people queasy.

Streaming with Suspense: do not wait for the slowest call

When a page requests four things, the usual shape is 80ms, 120ms, 150ms… and 900ms. If you wait for all of them before painting, your page takes 900ms. With streaming, the server sends the HTML for whatever is ready and the rest arrives later over the same connection.

In React you express this with Suspense boundaries. Each boundary is a promise to the user: "this part is coming later, the rest is already here".

export default function Dashboard() {
  return (
    <Layout>
      <Header />                          {/* instant, no data */}
      <Suspense fallback={<KpisSkeleton />}>
        <Kpis />                          {/* fast */}
      </Suspense>
      <Suspense fallback={<TableSkeleton rows={8} />}>
        <ReportsTable />                  {/* the slow one: 900ms */}
      </Suspense>
    </Layout>
  );
}

The syntax is not the interesting part — where you put the boundaries is. One boundary per page buys you nothing: you are back to the global spinner with extra steps. One boundary per tiny component is not better either: you end up with a screen that flickers in pieces for two seconds and feels broken. My rule is to wrap blocks the user perceives as a single unit — a card, a table, a sidebar — and above all to isolate the slow thing so it does not hold the fast things hostage.

The detail that killed the most complaints: delay and hold

If a request takes 90ms and you show a skeleton, the user sees a flicker: it appears and disappears before the eye can process it. That flash reads as a glitch, not as speed. A skeleton shown for 40ms is more annoying than no skeleton at all.

The fix is not technical, it is about timing: do not show the loading state before ~200ms, and once you show it, hold it for at least ~400ms.

export function useVisibleLoading(loading: boolean) {
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    if (!loading) return;
    const delay = setTimeout(() => setVisible(true), 200);
    return () => clearTimeout(delay);       // finished early: never seen
  }, [loading]);

  useEffect(() => {
    if (loading || !visible) return;
    const floor = setTimeout(() => setVisible(false), 400);
    return () => clearTimeout(floor);       // already visible: hold 400ms
  }, [loading, visible]);

  return visible;
}

With this, fast responses show nothing at all — which is what you want, since they are already fast — and slow ones show a stable state instead of a flicker. It is the smallest change I have shipped with the most direct impact on "hey, the app feels way better now".

What I do today on every new screen

Before writing the first fetch, I ask three questions: what can I paint with no data (that goes outside every boundary), which blocks does the user read as units (that is where the Suspense boundaries and their skeletons go), and which call is the slow one (that one always gets isolated, even if it is the most important thing on the screen).

The mental mistake that took me years to unlearn was thinking of loading as a binary moment — loading or loaded — when it is really a sequence you get to choreograph. The same 900ms request can feel like a broken app or a living one, and the difference is not in the backend: it is in what you choose to show during those 900ms. Getting that call from 900ms to 600ms costs an afternoon and almost nobody notices; designing what people look at in the meantime costs an hour and everybody does.

Yohangel Ramos

Written by Yohangel Ramos

Senior Fullstack Developer and Tech Lead. I build with React, Next.js, Nest.js and AWS — and I write about what I learn along the way.

Let's talk →

Keep reading

AWS

CloudFront in front of your app: why your hit rate is 30%

Frontend

INP: why your app feels slow even when it loads fast