React Suspense and lazy: Loading States Without Flicker
React Suspense shows a fallback while its children load. Covers React.lazy code splitting, boundary placement, streaming SSR, and avoiding layout flicker.
Every app ends up with the same block of code repeated in a dozen components: check a loading flag, return a spinner, otherwise return the real thing. Suspense replaces that with a declarative boundary. You say what to show while something is not ready, and React works out when to show it.
It has been in React since 16.6 for code splitting, and React 18 and 19 broadened it into something closer to a general loading primitive. It is also easy to place badly, and a badly placed boundary makes an app feel worse than no boundary at all.
What Suspense does
<Suspense> takes a fallback and some children. If anything in that subtree is not ready to render, React shows the fallback instead of the whole subtree, then swaps in the real content when it is.
import { Suspense } from 'react';
<Suspense fallback={<ProfileSkeleton />}>
<Profile userId={userId} />
</Suspense>The important word is subtree. A boundary is not attached to one component; it catches anything suspending anywhere beneath it, at any depth. That is what makes placement the interesting decision rather than an afterthought.
Code splitting with React.lazy
The oldest and still most common use. lazy takes a function that returns a dynamic import and gives back a component that loads its code the first time it renders.
import { lazy, Suspense } from 'react';
// Not bundled with the parent. Fetched on first render.
const Editor = lazy(() => import('./Editor'));
function Page({ editing }: { editing: boolean }) {
return (
<Suspense fallback={<div className="h-96 animate-pulse bg-muted" />}>
{editing && <Editor />}
</Suspense>
);
}The wins are real when the split is real. A rich text editor, a charting library, a date picker with a locale bundle: these are hundreds of kilobytes that most sessions never touch. Splitting a 4KB component achieves nothing except an extra request.
It needs a default export
lazy expects the module's default export to be the component. Point it at a named export and you get a runtime error rather than a type error:
// Error: Element type is invalid. Received a promise that resolves
// to: undefined. Lazy element type must resolve to a class or function.
const Chart = lazy(() => import('./charts'));
// Map the named export onto default yourself.
const Chart = lazy(() =>
import('./charts').then((m) => ({ default: m.Chart }))
);Preloading the chunk before it is needed
The downside of lazy is that the user waits for a network request at the exact moment they asked for something. You can usually predict that moment slightly earlier. A dynamic import returns a promise, and calling it a second time returns the cached module, so kicking it off on hover costs nothing and often removes the wait entirely.
const Editor = lazy(() => import('./Editor'));
// Same import, called early. The browser starts fetching on hover,
// and by the time the click lands the module is usually there.
const preloadEditor = () => void import('./Editor');
<button
onMouseEnter={preloadEditor}
onFocus={preloadEditor}
onClick={() => setEditing(true)}
>
Edit
</button>;Include onFocus alongside onMouseEnter or you have quietly built a feature that only works for people using a mouse. Keyboard and touch users get the slow path.
Fallbacks and the flicker problem
A spinner in a fallback is the default choice and usually the wrong one. Two things go wrong with it.
The first is layout shift. A 32px spinner sitting where a 400px card will be means the page jumps when the content arrives, and that jump is a Cumulative Layout Shift penalty on top of feeling cheap. Fallbacks should occupy roughly the space the real content will occupy. Skeletons are popular for exactly this reason: they are the right size by construction. Sizing one to match its real content across screen sizes is ordinary responsive work, of the kind covered in Tailwind CSS breakpoints.
// Reserves the same box the loaded card will fill.
function ProfileSkeleton() {
return (
<div className="h-40 rounded-xl border border-border p-6">
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
<div className="mt-3 h-3 w-48 animate-pulse rounded bg-muted" />
</div>
);
}The second is the flash. If the content resolves in 80ms, the user sees a skeleton appear and vanish, which reads as a glitch rather than as loading. There is no built-in delay for this, and the usual fix is a CSS animation that keeps the fallback invisible for the first couple of hundred milliseconds:
<style>
/* Nothing renders for 300ms, then the skeleton fades in.
Fast responses never show a fallback at all. */
.delayed {
animation: appear 200ms 300ms both;
}
@keyframes appear {
from { opacity: 0; }
to { opacity: 1; }
}
</style>Where to put the boundary
This is the decision that determines how the app feels, and there is a genuine trade-off rather than a best practice.
One boundary at the top of the page is the simplest thing to write. It also means that if any part of the page is slow, the entire page is a skeleton, including the parts that were ready instantly. The user waits on the slowest thing.
A boundary per widget shows each piece the moment it is ready. The risk is the opposite: eight boundaries resolving at eight different times makes the page visibly assemble itself, with content pushing other content around as it lands. That is worse than one clean wait.
// Header and nav render immediately. The two slow regions
// resolve independently, but each is a stable block that does
// not reflow its neighbours.
<Layout>
<Header />
<Suspense fallback={<FeedSkeleton />}>
<Feed />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</Layout>What I aim for is a boundary around each region a user perceives as a separate thing, and not below that. Grouping several fast siblings under one boundary is usually better than giving each its own, because they arrive together and the layout settles once.
Suspense does not catch errors
A suspended component that then fails throws past the Suspense boundary. There is no error prop and no fallback for the failure case. You need an error boundary, and it goes outside.
<ErrorBoundary fallback={<p>Could not load this section.</p>}>
<Suspense fallback={<FeedSkeleton />}>
<Feed />
</Suspense>
</ErrorBoundary>Put the error boundary inside and it will not catch anything useful, because the Suspense fallback is what renders while the child is throwing a promise. Outside is correct. React still has no hook-based error boundary, so this is one of the last places a class component or a library such as react-error-boundary is genuinely required.
Suspense for data, not just code
For years Suspense could only be triggered by lazy. React 19 shipped use, which reads a promise and suspends the component until it resolves.
import { use, Suspense } from 'react';
function Profile({ userPromise }: { userPromise: Promise<User> }) {
// Suspends here until the promise settles.
const user = use(userPromise);
return <h2>{user.name}</h2>;
}
function Page() {
// Created outside the suspending component, not inside it.
const userPromise = fetchUser(1);
return (
<Suspense fallback={<ProfileSkeleton />}>
<Profile userPromise={userPromise} />
</Suspense>
);
}In practice, most client-side data fetching still goes through a library rather than raw use, because the library owns the caching that makes the promise stable in the first place. TanStack Query has useSuspenseQuery for this, which suspends instead of returning an isLoading flag. The options are compared in fetching data in React.
What happens to state behind a fallback
Worth knowing because it changed, and the old behaviour is still what most search results describe. Before React 18, when a boundary fell back, the children were unmounted. Their state was destroyed, their effects were cleaned up, and coming back from a fallback meant starting over.
React 18 changed this to hide the subtree with display: none instead. State survives, and effects are cleaned up on the way in and re-run on the way out. In practice that means a tab you switch away from and back to keeps its scroll position and its half-filled form, which is what users expect and what the old behaviour got wrong.
The part that catches people is the effects. An effect that starts a subscription will be torn down and re-established when content hides and reappears, so it needs to be genuinely idempotent. This is the same requirement Strict Mode enforces in development, and it is one more reason not to disable it, as I argued in useEffect in React.
Keeping the old UI instead of the fallback
There is a failure mode where Suspense actively hurts. A user types in a search box, results are already on screen, and every keystroke replaces them with a skeleton. The page flashes constantly and the user loses their place.
The fix is to mark the update as a transition. React then keeps the current content visible while the new content loads, rather than falling back.
import { useTransition, useState } from 'react';
function Search() {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState('');
function onChange(value: string) {
// Urgent: the input updates immediately.
setInputValue(value);
// Non-urgent: results can lag, and the old ones stay on screen.
startTransition(() => setQuery(value));
}
return <input onChange={(e) => onChange(e.target.value)} />;
}Use isPending to dim the stale results or show a small inline spinner. The rule of thumb: a Suspense fallback is for content that has never been shown, and a transition is for content being replaced. Getting that distinction right is most of what separates an app that feels responsive from one that feels twitchy.
Streaming on the server
Suspense on the server does something different again. Rather than holding the whole HTML response until every query finishes, React sends the shell immediately with fallbacks in place, then streams each region's real HTML as it becomes ready.
This is what makes the Next.js App Router's loading.tsx work: the file is sugar for wrapping a route segment in a Suspense boundary. A slow database query in one component no longer delays the first byte for the entire page, which moves Largest Contentful Paint in a way client-side loading states cannot. I go through the mechanics in React Server Components in Next.js.
What I would actually do
Reach for lazy when a route or a heavy widget is genuinely optional, and check the bundle analyser afterwards to confirm the chunk moved. Place boundaries around perceived regions, not individual components. Size every fallback like the content it replaces, and delay it a few hundred milliseconds so fast responses never flash.
And wrap them in error boundaries from the start. A Suspense boundary with no error boundary above it turns a failed request into a blank screen, which is the one loading state worse than a spinner.
Frequently asked questions
Does Suspense catch errors?
No. A component that suspends and then fails throws past the Suspense boundary. You need an error boundary, and it goes outside the Suspense boundary rather than inside it. React still has no hook-based error boundary, so this is one of the few places a class component or a library such as react-error-boundary is genuinely required.
Where should I put a Suspense boundary?
Around each region a user perceives as a separate thing, and no lower. One boundary at the top of a page means the whole page waits on the slowest query. A boundary per component makes the page visibly assemble itself as pieces arrive at different times. Grouping fast siblings under one boundary is usually better than giving each its own.
Why does my lazy component lose its state?
You are almost certainly calling lazy() inside a component body, which creates a new component type on every render. React treats each one as a different component, unmounts the old tree and remounts a fresh one, so all state is lost. Declare lazy components at module scope.
How do I stop the Suspense fallback flashing on every keystroke?
Wrap the update in startTransition or useTransition. React then keeps the existing content on screen while the new content loads instead of falling back to the skeleton. A Suspense fallback is for content that has never been shown; a transition is for content being replaced.


