loading.tsx Is Not a Spinner
A loading file is a Suspense boundary around a whole route segment, which means it decides how much of your page waits for your slowest query. Most of them are placed by accident.
Dropping a loading.tsx into a route gives you an instant loading state, and
that is where most teams stop reading. The file is not a spinner component. It
is the fallback of a Suspense boundary that Next.js wraps around the entire
segment, and the boundary is the part that matters.
What that means in practice: everything inside the segment waits for the
slowest thing in it. One loading.tsx at the top of a dashboard route turns
a page with a fast header, a fast sidebar and one slow chart into a page that
shows nothing until the chart is ready.
The header and the sidebar were ready in 40ms. The visitor saw a skeleton for two seconds because the boundary was drawn around all three.
Where the boundary belongs
Around the slow thing, not around the page.
// app/dashboard/page.tsx - no loading.tsx in this directory.
import { Suspense } from 'react';
export default function Dashboard() {
return (
<>
<Header /> {/* renders immediately */}
<Sidebar /> {/* renders immediately */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* the only thing that waits */}
</Suspense>
</>
);
}The shell streams first, the chart arrives when it arrives, and the visitor has something to read and something to click in the meantime. Same total time, completely different experience - and a much better LCP, because the largest painted element is no longer a skeleton.
When a route-level loading file is right
It is not always wrong. Use one when the whole segment genuinely depends on a single fetch - an article page where there is no page without the article, a detail view that is one record. There, a route-level fallback is honest: there really is nothing to show yet.
The test is one question: is there anything on this page I could render before the data arrives? If yes, the boundary is in the wrong place.
Two details that cost people an afternoon
A loading.tsx applies to its segment and every segment below it. A file at
app/loading.tsx is a fallback for the entire application, which is almost
never what someone meant when they created it while working on one route.
And the fallback must reserve the space its content will occupy. A skeleton 120px tall in front of a 400px chart is a 280px layout shift the moment the data lands, and CLS does not care that the shift was your own loading state.
function ChartSkeleton() {
// Same height as the chart, so nothing moves when it swaps in.
return <div className="h-[400px] animate-pulse rounded-xl bg-ink-100" />;
}That is the whole of it. loading.tsx is a convenience for one case, and
<Suspense> is the tool for every other one - which is worth knowing before
the convenience makes the decision for you.
