Skip to content
DERKONLINE

Design an App Shell So Users Never Stare at a Spinner

App shell, skeletons, Suspense streaming, and prefetch make every screen feel instant instead of waiting on the network.

Derrick S. K. Siawor7 min read

Watch someone use a slow app and you will see the same small defeat over and over: they tap, and then they wait, staring at a spinner spinning over a blank screen. Nothing tells them how long it will take or what is coming. That blank-with-a-spinner moment is where apps feel cheap, and it is almost always avoidable. The screen does not have to be empty while data loads. It can show the shape of what is arriving, stream content in as it becomes ready, and for predictable data, have already fetched it before the user even asked.

The goal is that no screen ever makes the user stare at a spinner over a void. Getting there is a combination of three techniques: an app shell that renders instantly, skeletons that show structure while data loads, and prefetching that does the work before the user clicks. React 19 and modern frameworks make each of these a first-class pattern rather than something you hand-roll with loading flags.

The app shell renders before the data

The core idea of an app shell is that the parts of a screen that do not depend on data, the header, the navigation, the layout, the page chrome, should appear instantly while the data-dependent parts fill in. The user sees a real, structured page immediately, and the dynamic content streams into it. This is the opposite of the all-or-nothing approach where the entire screen waits for the slowest query before rendering anything.

In a framework with file-based routing, a route-level loading file gives you this almost for free: it automatically wraps the page in a Suspense boundary and renders a loading UI while the page's data is fetched. The shell, your shared layout, stays on screen the whole time, and only the page body shows the loading state. Navigation feels immediate because the frame is already there; only the contents are pending, which is also why server components that fetch data without the waterfall tax keep that frame from waiting on a chain of sequential requests.

Skeletons over spinners

What you render while data loads matters more than people think. A spinner says "something is happening" and nothing else. A skeleton says "here is exactly what is coming," a gray block where the heading will be, three lined placeholders where the list rows will be, a rounded rectangle where the avatar will be. The user's brain reads the structure and prepares for the content, which makes the wait feel dramatically shorter even when the actual load time is identical. Well-implemented skeleton states can cut perceived loading time substantially while the real bytes are still in flight.

The reason a skeleton beats a spinner is not aesthetics, it is the layout. A skeleton occupies the exact space the real content will occupy, so when the data arrives it drops into place with no shift, no jump, no content reflowing under the user's thumb. A spinner sits in the middle of an empty box, and when content loads the whole layout snaps into existence. The skeleton is the loading state that respects the final shape; the spinner ignores it.

Match the skeleton to the real component. A card skeleton looks like a card, a table skeleton looks like a table with the right number of placeholder rows. Add a subtle shimmer to signal it is a placeholder and not broken content, and keep the dimensions identical to the loaded state so nothing moves when it resolves. On a slow connection, this is also where the right service worker caching strategy lets the shell render from cache while the fresh data streams in behind it.

Stream the slow parts independently

A page rarely has just one data source. A dashboard might pull a summary, a chart, and a recent-activity feed, each with its own latency. If you wait for all three before showing anything, the whole page moves at the speed of the slowest query. Streaming fixes this by letting each section resolve on its own timeline.

Wrap each independent section in its own Suspense boundary with its own skeleton fallback. The fast sections appear as soon as their data is ready, and the slow ones stream in afterward, each replacing its skeleton when it resolves. The summary shows up instantly, the chart fills in a beat later, the activity feed last, and the user is reading the summary while the rest arrives. No single slow query holds the whole screen hostage.

App shell renders instantly while independent Suspense boundaries stream content in parallel

<DashboardShell>
  <Suspense fallback={<SummarySkeleton />}>
    <Summary />
  </Suspense>
  <Suspense fallback={<ChartSkeleton />}>
    <Chart />
  </Suspense>
  <Suspense fallback={<FeedSkeleton />}>
    <ActivityFeed />
  </Suspense>
</DashboardShell>

Each boundary is independent, so the page assembles itself piece by piece in priority order rather than appearing all at once after a long wait.

Prefetch what you can predict

The fastest loading state is the one that already finished before the user asked for it. A large share of navigation in an app is predictable: a user hovering a profile link is very likely to click it; a user on step two of a flow is going to reach step three; a user looking at a list is probably going to open an item. For all of these, you can fetch the data ahead of the click.

Preload the next view's data when the link enters the viewport or when the user hovers it, the same idea that makes page navigations feel instant with speculation rules. By the time they actually click, the data is in cache and the navigation uses work you already did, so the destination renders instantly instead of starting a fresh fetch. The key is to prefetch only what is genuinely likely, on hover or on viewport entry, so you get the speed without firing wasteful requests for pages nobody visits.

We take this further for anything in the app's chrome. Any dropdown, panel, switcher, or settings view whose data is knowable when the screen mounts should prefetch that data on mount, then quietly revalidate when it opens. The user should never see "loading" for data the interface already knew it would need. A room switcher, a notifications panel, an account menu, these have predictable contents, so we fetch them ahead of time and the panel opens already populated, the same way an empty state should already be primed to drive the next action rather than sit blank. Lazy-fetching chrome data on click is a small delay the user feels every single time, and it is entirely preventable.

The discipline is anticipation

What ties these together is a mindset: anticipate every predictable data point and never make the user wait for something the interface could have known it needed. The app shell anticipates that the layout will be the same and renders it first. The skeleton anticipates the shape of the content and reserves its space. Streaming anticipates that sections resolve at different speeds and lets the fast ones through. Prefetching anticipates the next click and does the work early.

The result is an app where every screen feels instant, not because the network got faster, but because the interface stopped waiting on the network to show the user something useful. That is the standard we hold on every product, the same on mobile as on desktop: maximum perceived performance, no spinner over a void, no panel that says "loading" for data we could have prefetched. Building interfaces that feel instant on every screen is core to the web apps and mobile apps we ship, where the difference between a tap that resolves now and a tap that makes someone wait is the difference between a product that feels premium and one that feels slow. Show the shape, stream the content, fetch ahead, and the wait disappears.