Skip to main content

Move Loading Up a Layer and Your App Feels Faster

Rare Ivy
Rare IvyMarketing Manager
10 min read
Move Loading Up a Layer and Your App Feels Faster

The fastest loading state is the one users never see

A user clicks a link, waits a beat, and lands on a spinner. Or a skeleton. Or one of those half-rendered pages where the header is there, the main content is missing, and a few boxes sit around pretending everything’s fine. “ They think the app feels slow.

That reaction isn’t always about backend speed. A server can respond in a decent amount of time and the experience can still feel clumsy if the UI waits too long to ask for data. Perceived speed lives in a different place than raw response time. Users care about what they see after the click, not the number buried in a trace or the fact that the API returned in 180 milliseconds after the page had already stalled for 600.

That gap matters. If the interface arrives empty and then fills itself in piece by piece, the app asks people to sit through the plumbing. They see the loading indicators, the layout shifts, the flicker between states. Even when the actual fetch is fast, the delay gets amplified because the screen is advertising that it’s unfinished. The backend may be doing fine. The page still feels like it’s catching up with itself.

This is why loading states often point to the wrong problem. They’re usually treated as a design feature, something to polish and soften. In practice, they’re often a symptom of late data fetching. The app waited until a component mounted, then started work, then asked the browser to hold the user’s attention while everything else lined up. That sequence is where the frustration comes from.

Move the work earlier and the experience changes. If data fetching happens before the user reaches the screen, the page can arrive mostly ready instead of showing its construction process. The goal isn’t to erase every wait, because that’s not realistic. The goal is to move the wait to a place the user doesn’t feel as much. A short pause before navigation completes is usually easier to tolerate than a visible stall after the page appears.

That’s the core idea behind route-level preloading and other earlier fetch patterns. Don’t make the screen start from zero if you can avoid it. Start the request when intent is clear, warm the cache before the view mounts, and let the destination render with real data already in hand. The interface has less explaining to do, which is a nice change for everyone involved.

The best loading state is often the one the app quietly outruns.

This doesn’t mean every screen must block on every request. “ When the app arrives with content already in place, users stop noticing the machinery. They just see a screen that feels immediate, even if a fair bit of work happened before it got there.

That’s the part worth keeping in mind as we look at the rest of the pattern. The problem usually isn’t that your app is too slow in some abstract sense. It’s that the waiting happens in front of the user, right after the click, where it hurts most.

Why component-level fetching makes pages feel sluggish

Why component-level fetching makes pages feel sluggish

The problem with mount-time fetching is simple: the browser has already done a fair bit of work by the time the request even starts, yet the page still can’t show much of anything useful. The route changes, the component mounts, the fetch kicks off, and then everyone waits together. On a fast connection, That may only be a few hundred milliseconds. In practice, it still feels clumsy because the user is staring at a screen that hasn’t settled into place.

That gap matters more than raw API speed. A backend can respond quickly and still produce a slow-feeling interface if the app refuses to ask for data until the last possible moment. “ Users don’t measure that delay with a stopwatch. They feel it as hesitation.

This gets worse when data dependencies are split across the component tree. A parent route renders, then a child widget mounts and fetches its own data. The child can’t draw until the parent is ready, and the parent may already be waiting on another request. com/query/latest/docs/framework/react/guides/request-waterfalls), and the term fits because each request starts after the one before it has already blocked progress. One fetch waits on another. Then another waits on that one. Nothing is technically broken, but the page arrives in pieces.

You see the same thing in nested layouts. A dashboard shell might load first, then the summary panel, then a table, then a row action menu, then a details drawer after the user clicks something. Each piece has its own fetch, its own loading condition, and its own idea of when it’s safe to appear. The result is a staggered reveal that looks more like a construction site than an app. Even when each request is fast, the total time until the screen feels done can get long.

That staggered reveal also hurts perceived performance in a very ordinary, annoying way. People don’t just notice the wait. They notice the motion. A screen that shifts from blank to skeleton screens to partial content to final content gives the eye too many states to process. One moment the list is there, the next it isn’t, then it comes back with a different height, then a button moves because the text wrapped after data arrived. Nothing is catastrophically wrong, but the interface feels a bit unsteady.

Component-level fetching makes that instability hard to avoid because every component invents its own loading story. One piece uses a spinner. Another uses a gray rectangle. A third shows an empty state while data is pending, which is a charming way to tell users the feature has failed before it has even started. Errors pile up in the same way. Now you need a retry button here, a fallback copy block there, and a separate timeout message for the widget that calls a different endpoint. The codebase slowly acquires a small museum of loading states, each one slightly different, each one needing its own testing path.

That maintenance burden is usually larger than teams expect. A local loading flag is easy to add and annoying to reason about later. Does this widget fetch on mount, on tab change, or when a parent prop changes? Does the spinner stay visible during a refetch? Should cached data show immediately, or should the component block until the fresh response lands? Once these choices are made in half a dozen places, the app starts to behave inconsistently. Users can’t name the bug, but they do notice that one screen feels snappy while another pauses for no obvious reason.

There’s also a layout problem that shows up during navigation. If a route renders its shell before the data is ready, you get a page that looks structurally complete but functionally empty. If child components then fill in one by one, spacing changes under the user’s cursor. Buttons jump. Headings shift. Tables appear and push everything down. The page is technically loading, but it feels unfinished in a way that draws attention to the delay rather than hiding it.

The more places that own their own loading state, the more opportunities the UI has to flicker, stall, or change shape after the user has already arrived.

Sometimes teams accept that tradeoff because the code is easy to write where the component lives. That’s fair. It’s also how a lot of apps end up with a dozen tiny loading decisions instead of one clean one. The problem isn’t the spinner itself. It’s the fact that the spinner sits too deep in the tree, after the app has already committed to rendering the screen.

Once you look at it that way, the fix becomes easier to see. The fetch shouldn’t start when the page appears. It should start before the page needs it, so the view can arrive with data already in hand or with a single fallback at the boundary. That shift changes the feel of the whole app, and it also trims a pile of local state that nobody particularly wanted to maintain in the first place.

Move the fetch up a layer

Once you’ve seen the mess that mount-time fetching creates, the next move is less glamorous and more effective: stop waiting for the page component to ask for data. Fetch earlier. Fetch closer to navigation. Fetch at the moment intent is clear, before the user has fully landed on the screen.

That can happen in a few places. A hover over a link is often enough to start warming the next route. So is keyboard focus, which matters more than people admit because keyboard users don’t enjoy waiting for your UI to catch up to their tab key. com/query/latest/docs/framework/react/guides/prefetching) in the background rather than making the user sit through a blank screen after the click. In router-driven apps, route-level preloading can do the same job with less duplication. The route, not the widget tree, becomes the place where the data request begins.

That shift changes the shape of the code. Instead of three child components each deciding whether to show a spinner, a placeholder, or an error message, the screen boundary handles the one question that matters: do we’ve enough data to render this view yet? If the answer is no, show a single fallback at the boundary. If the answer is yes, let the inner components assume their props exist and get on with their job. That keeps the tree calmer. It also makes the failure modes easier to reason about, which your future self will appreciate after the third refactor and a small amount of caffeine.

One fallback at the edge beats four loaders fighting for screen space.

Route loaders are a good fit here because they let the next screen declare its data needs before the component renders. In practice, That means the route can fetch the user profile, invoice list, document metadata, or whatever else the page needs, then hand a ready-to-use result to the view. You’re not waiting for the main component to mount before the request even starts, which is usually where that sluggish feel comes from. A route loader also makes data dependencies obvious. If a screen needs three pieces of information, you can ask for them once at the route boundary instead of letting each nested widget do its own little dance.

Caches help even more than people expect. A warmed local cache means the second visit feels different from the first one, and back/forward navigation stops behaving like a punishment. The browser history buttons should feel boring. That’s a compliment. When the user goes back to a list they already opened thirty seconds ago, the app can show cached data right away and refresh in the background if needed. TanStack Query’s cache model is built for this sort of thing, but the general rule applies whether you use that library or not: keep data around if the user is likely to ask for it again soon.

The tricky part is stale data, because nobody wants a screen that’s fast and wrong. “ The app can render cached content immediately, then revalidate after arrival if the data might have changed. That works especially well for dashboards, inboxes, and review queues where the shape of the page is stable but the numbers or records can drift. You avoid blocking every render on a fresh network trip, which would be a strange way to treat frontend performance, but you still keep the data honest enough for day-to-day use.

Slow networks need a different kind of mercy. If the request hasn’t finished by the time the user arrives, the fallback should be predictable and stable, not a stack of nested spinners popping in and out as each component wakes up. dev/reference/react/Suspense) boundary is useful here because it gives you one place to decide what “waiting” looks like. The inner tree can stay simple. The route shell can stay visible. You get one loading surface instead of a dozen tiny apologies.

For apps with obvious next steps, prefetching on intent can do a surprising amount of work for you. A document workflow app, for example, might preload the next record when the user hovers a row, then reuse that cached payload if they click through. The same pattern works for search results, detail pages, settings screens, and any flow where the next destination is easy to guess. The app feels snappier, but the gain isn’t magic. It’s mostly timing, a cache that does its job, and fewer components trying to reinvent loading state in the middle of the page.

If your router supports it, link-level preloading can make the transition even smoother. run/docs/en/main/route/links) API, for example, lets you start work before the click completes, which is exactly the sort of boring, practical trick that pays off later. Users don’t need to know why the screen arrived ready. They just notice that it didn’t make them wait around like an awkward first date.

A faster-feeling app is mostly a timing decision

At this point, the pattern is pretty simple. Most of the pain people call “slow” isn’t a slow server at all. It’s a late fetch. The app waits until a page or widget appears, then starts asking for data, and the user gets stuck watching the UI explain itself in little fragments: spinner here, skeleton there, half a table, a missing chart, an error card that shows up five seconds after the click. None of that feels fast, even when the actual request finishes in decent time.

Move the work earlier and the whole experience changes. If the data is already on its way before navigation completes, the next screen can usually land in a more complete state. That means fewer blinking placeholders, fewer “loading…” labels scattered through the page, And fewer moments where the interface looks like it’s still under construction. Users don’t need to know whether the fetch started on hover, on route transition, or during a parent loader. They just notice that the screen arrives ready enough to use.

That timing shift also cuts down the mess behind the scenes. Once loading logic is pushed deep into the component tree, every piece of UI starts inventing its own version of the same problem. One widget wants a spinner. Another wants an empty state. A third needs a retry button. Then the error handling splits the same data concern across three files, and the codebase grows a small forest of slightly different loading paths. Fun for nobody, least of all the person who has to touch it six months later.

Keep the boundary clean and the app gets easier to reason about. The route, page, Or screen-level layer decides whether data is ready. Inner components can assume they’ve what they need, or at least a well-defined fallback from the boundary above them. That usually produces cleaner UI and fewer edge cases, because there are fewer places where partial state can leak through. It also makes testing less annoying, which is a nice bonus when you’re trying to ship something before dinner.

For app loading speed, the useful mental model is boring in the best possible way: make users wait before they arrive, not after they land. If the work happens early enough, the interface doesn’t need to apologize as much. If the data is cached or preloaded, The app can move straight to the useful part. And if a fallback is needed, it lives in one place instead of peppering the page like confetti nobody asked for.

That’s the real payoff. Less visual noise. Less code. Fewer states to coordinate. A screen that feels ready instead of tentative. The browser still does the same amount of work somewhere, sure, but timing changes the experience more than people expect. In practice, the fastest loading state is often no obvious loading state at all.

Newsletter

Stay in the loop

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