Skip to main content

The Better Pattern for Static Sites: Preload, Then Navigate

Christina Hill
Christina HillMarketing Manager
11 min read
The Better Pattern for Static Sites: Preload, Then Navigate

The Real Problem Behind the Spinner

A spinner is usually not the problem. It’s the symptom.

When users keep hitting loading states, the app is telling on itself. It waited until after the click, after the route change, after the new screen was already requested, and only then started gathering the data it needed. By the time the request even begins, the user has already made the next move. The interface is playing catch-up, and that lag is exactly what people feel.

That’s why repeated spinners are so often a timing problem, not a rendering problem. If the next page can’t appear until several async calls finish, every navigation becomes a little waiting room. Maybe the header loads first, then the shell, then a card grid, then the details panel, then the comments, each piece arriving on its own schedule. The page technically works. It just doesn’t feel like one page. It feels like several small network requests wearing a trench coat.

In apps with scattered async work, route changes can turn into mini loading problems everywhere you look. One component fetches user data on mount. Another asks for permissions after it renders. A third reaches for related records only after it discovers which record it’s on. None of that’s unusual. In fact, it’s how a lot of apps grow. But the side effect is predictable: the route itself is no longer the unit of work. Each widget becomes its own little delay, with its own spinner, skeleton, or empty gap.

That fragmentation matters because users don’t experience your app as a set of independent fetches. They experience a simple action. “ From their point of view, the app should either be ready or not ready. When different parts of the screen wake up at different times, The transition feels broken into pieces. Even when the total wait time is short, the interface can feel busier than it needs to be. Busy isn’t the same thing as responsive.

A cleaner approach starts by moving the wait earlier. Fetch before navigation when you can, cache what you’ve already seen, and make the destination cheap to render when the user lands there. That can mean route prefetching on hover or intent, loading route data before the transition completes, or keeping recently used data around long enough to reuse it. The point is simple: do the slow work before the screen swap, not after it.

If the user has already asked to go somewhere, the app should be preparing the next screen, not negotiating with the network from scratch.

This is where the shape of the UI changes. Instead of lots of small loading states scattered through the tree, you can keep one fallback for the rare case where the app truly has nothing ready yet. Most transitions should feel like fast, complete renders, with the data already in place when the route appears. When loading does show up, it should feel exceptional, not habitual.

That shift also changes how you debug the app. A spinner stops being a decoration choice and starts pointing at where work begins too late. Was the request tied to component mount when it could have been started on intent? Was the data fetched deep in the tree when it could have been prepared one level up? Was the route waiting on three separate calls when one preloaded response would have done the job? Those questions usually get you closer to the real issue than polishing the spinner ever will.

So the first fix isn’t to make loading states prettier. It’s to make them rarer. Start earlier, cache sooner, and let the next screen arrive ready enough that the user barely notices the handoff.

Why Component-Level Loading States Fail

Why Component-Level Loading States Fail

At first glance, letting each card, panel, or route manage its own loading state seems tidy. A component fetches its data, shows a spinner, and swaps in the real content when the request finishes. Nice and local. The trouble is that this approach turns one user action into a small parade of waits. The sidebar loads. Then the main panel loads. Then a nested widget fetches its own details. By the time the page settles down, the user has watched three different placeholders, and none of them were coordinated.

That fragmentation shows up visually right away. One card on the page fills in while the one next to it’s still blank. A table row expands after the rest of the table has already shifted. A route change lands on a page whose header is ready, but the body is still swapping skeleton blocks in and out. The interface feels unstable because the geometry keeps changing. Text moves. Buttons shift. Images pop into place late. Even if each individual component is doing exactly what it was told, the page as a whole reads as unfinished.

The deeper issue is that component-level loading states often hide where the work should have happened instead. A spinner on a card can mean the request was never cached. It can mean the app waited until the component mounted before asking for data. It can mean the route transition finished before the next screen had anything useful to show. It can also mean the data lived so far down the tree that no parent component had a chance to gather it early and render a coherent page. In other words, the spinner is often a symptom of late data access, not a feature worth celebrating.

React apps make this especially easy to stumble into. A page renders, a child component runs useEffect, the request starts, and now the child has to invent a loading state. Multiply that pattern across five or six components and you get a page that’s technically “working” while still feeling slow. The code may look modular. The experience doesn’t. Each piece is solving its own problem, but nobody is solving the whole transition.

This is where the difference between busy UI and responsive UI gets sharp. A busy UI shows activity. A responsive UI gives the user a fast sense that the app understood the request. Those aren’t the same thing. A screen full of animated placeholders can keep people occupied for a moment, but it doesn’t make the app faster. It just gives the delay a costume. If the request starts after the navigation begins, the user still waits. If the data is fetched one component at a time, the wait is just split into smaller chunks.

Layout shifts make this worse. A skeleton card that’s 180 pixels tall and a final card that’s 260 pixels tall will push content around when the real data arrives. A collapsed placeholder that expands after the fetch can move the rest of the page down. If several components do that independently, the page feels restless. The browser has to repaint more often, the user has to re-scan the page, and any quick action they wanted to take gets delayed by a screen that keeps changing under them. On static sites, this can show up after hydration too, when client-side data fills in what the server didn’t render.

There’s also a subtle cognitive cost. When every widget has its own waiting state, the user has to keep checking which parts are done and which parts are still in flight. That’s fine once. It gets annoying fast. One global page transition is easy to understand. Seven little loaders aren’t. They force the user to interpret the app’s internal state instead of just using the page.

A better rule is to make the waiting visible in one place, not everywhere. If the app truly needs a fallback, It should be the exception, not the default decoration on every component. That means moving the request earlier in the lifecycle, before the screen depends on it. hl=en) only make sense when the app knows what’s next soon enough to ask for it. If the data request starts after the route change, no amount of tiny spinners will fix the wait.

So the real failure of component-level loading states isn’t that they look ugly. It’s that they normalize late work. They let every piece of the interface ask for data on its own schedule, which is usually too late for a smooth transition. The cleaner pattern is to coordinate that work before the user lands on the next screen, then show as little fallback UI as possible when they get there. That’s where the next section goes.

Preload First, Then Navigate

If the last section made one thing obvious, it’s that a spinner usually means the app started thinking too late. The nicer pattern flips the order. Start fetching the next route before the user lands there, so the destination is waiting when they arrive. The route change stops feeling like a request and starts feeling like a handoff.

That shift can happen off small, ordinary signals. A hover on a link is the obvious one, and it’s still useful because people often hover right before they click. A menu item that gets focus by keyboard is another good cue. So is a card sliding into the viewport, which tells you the user has at least glanced in that direction. Route prediction can also help when the app can guess the next screen from earlier behavior, though that gets messy if you try to be too clever. Nobody enjoys an app that preloads the wrong page because it got enthusiastic about mind reading.

The point isn’t to fetch everything all the time. It’s to fetch the likely next thing early enough that navigation feels instant. In practice, that usually means prefetching route data, caching the response, then letting the router move the user to a screen that already has what it needs. On a good day, the page renders immediately. On a less cooperative day, you still only show a thin fallback for a moment, not a field of spinners scattered across the interface.

That fallback can be one app-level loading state instead of a dozen local ones. This is the part many teams miss. If each component waits on its own request after navigation, you get a weird little parade of blank panels, half-drawn cards, and text that keeps jumping around. If the app owns the transition, the user sees one clear signal: the new screen is on its way. One fallback is easier to understand than six tiny apologies.

A lot of frameworks already support some version of this. js, for example, has route prefetching built in for many link interactions, so a page can begin warming up before the click lands. org/docs/app/guides/prefetching) are worth a look if you’re wiring this up in the App Router. Under the hood, the idea is simple enough: prepare the next route while the current route is still visible, then reuse the prepared work when the user moves. Browser-level resource hints work the same way in broader terms. org/en-US/docs/Web/HTML/Reference/Attributes/rel/prefetch) covers the basics. hl=en) if you want the larger picture.

Fetch first, then move. If the next screen already has data, the interface feels calm instead of busy.

That calm feeling comes from the flow, not from fancier visuals. Prefetch the route data. Store it in cache or whatever client state layer you’re already using. Navigate. Render immediately if the data is warm, or show only a tiny transition if the handoff still needs a beat. The user shouldn’t have to watch every fetch happen in public.

This is where stale-while-revalidate fits nicely. The app can show cached content right away, then refresh it in the background if the data might have changed. The user gets speed first and freshness a moment later, which is usually a better trade than making them wait for the newest possible value on every visit. For many products, that trade is fine. A product list from thirty seconds ago is still a product list. A blank screen, on the other hand, is just an excuse to stare at the cursor.

Cached navigation works on the same principle. If the app has already seen a route, it can keep enough information around to make the next visit fast. That might be the rendered payload, A JSON response, or just enough metadata to avoid a cold start. The exact mechanism varies by stack, but the user-facing result should be the same: the second time through, the app should feel like it remembers them.

Skeleton screens can still have a place here, but they become a backup, not the main event. Use them for genuinely uncertain moments, like a first visit or a route that depends on live data you can’t reasonably predict. If you find yourself leaning on skeletons for every screen, that usually means the app is still waiting too late. Skeletons can soften the rough edges, sure. They don’t solve the timing problem.

A small rule of thumb helps: if you can guess the next destination, start work before the click. If you can’t guess it reliably, keep the fallback simple and make the transition short. That gives you a system that feels quick without pretending every route can be fully preloaded. Some screens will still need real-time fetching, and that’s fine. The trick is to stop treating delay as the default mode.

Used this way, preload-first navigation changes the feel of the whole app. The interface spends less time saying “hang on” and more time just opening. That’s the part users remember, even if they never put a name on it.

Applying It to Static Sites and Jamstack Apps

Static sites are already halfway to this pattern. That’s the good news. The less glamorous news is that people still bolt on runtime fetches everywhere, then act surprised when the page waits around like it forgot why it came in.

The cleaner move is simple: build what you can at publish time, and leave live requests for the bits that actually need to change often. A blog post, marketing page, docs article, pricing page, or portfolio project usually doesn’t need to be assembled from scratch when someone clicks it. If the content exists before the user arrives, the browser has far less to negotiate. The page can render fast, route changes feel lighter, and your loading UI can stay in the drawer most of the time.

That maps neatly to Jamstack habits. js, some pages can be generated statically and served from the edge, while dynamic fragments can still be fetched after render when freshness matters. In Hugo and Jekyll, The whole point is that most of the page is already written into the output at build time, so the server doesn’t need to improvise on every visit. Plain HTML gets the same benefit in the simplest possible way: ship the file, cache the file, and stop asking the browser to assemble a brochure one div at a time. If you need live data, pull only that piece, not the whole page.

Caching does a lot of the boring work here, which is exactly what you want. A CDN can keep static pages close to the user, and browser caching can make repeat visits almost boringly quick. If a visitor clicks from your homepage to a case study, there’s a decent chance the shell, fonts, and shared assets are already sitting in cache. That means the next route doesn’t have to negotiate for every byte. The same logic applies to route data too. If you prefetch or cache it before navigation, the user sees a page instead of a spinner, or at worst a very brief transition.

That’s where frontend performance stops being a dashboard number and starts feeling like a calmer interface. Fewer waiting states means fewer layout jumps, fewer half-painted cards, and less of that “this page is still putting on shoes” feeling. When the common path is already built and cached, the app only needs a fallback for the awkward cases: first visits, expired data, or content that really does change every minute. A stock ticker, live inventory count, or personalized feed may still need runtime fetching. Fine. Let those routes use a fallback. Just don’t make the fallback the default costume for everything else.

If most of your pages still need a spinner, the page probably should’ve been built earlier.

For static site builders, this usually means a few practical habits. Pre-render article pages, docs, product pages, and landing pages. Use incremental builds or revalidation only where content changes enough to justify it. Prefetch linked pages when the browser can predict intent, like on hover or when a link enters the viewport. Keep JSON payloads or route data small, and cache them when they don’t need to be fresh on every click. js route data, but it also helps on simpler stacks where the “route” is just another HTML file with a little script on top.

The nice part is that this doesn’t demand a full rewrite. A Hugo site can ship faster by making better use of build output and CDN caching. A Jekyll site can keep most pages static and leave only the dynamic bits to client-side fetches. A plain HTML site can use preloaded assets, cached responses, and a little JavaScript to fetch only what changes. Even a small adjustment, like moving data fetches out of the deepest component and into a page-level request, can cut down on noisy loading states.

So the rule of thumb is pretty plain: if the content can exist before the click, let it exist before the click. Save runtime work for the cases that truly need it. Then the browser has a much easier job, and the user gets a page that feels ready instead of one that’s still assembling itself after arrival.

Newsletter

Stay in the loop

Join our newsletter and get resources, curated content, and inspiration delivered straight to your inbox.