Lesson 67 of 81
loading.tsx wires itself up automatically — what it renders is entirely on you, and a centered spinner is the one choice guaranteed to cause a layout shift the moment real content arrives.
12 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
loading.tsx automatically becomes a Suspense fallback for its segment — the wiring is free. What you put inside it is a design decision the framework has no opinion on, and the easy default, a centered spinner, is almost always the wrong one. A spinner sits in the vertical and horizontal center of whatever space it's given, with no relationship to what's about to replace it. When the real content — a list, a grid of cards, a form — streams in, the layout doesn't smoothly transition; it jumps, because the spinner's box had different dimensions than the content that replaces it. That jump is a real, measurable layout shift, and it's the same category of problem this project's own Core Web Vitals work already treats seriously elsewhere in the app.
A skeleton fixes this by being a rough, gray-block stand-in that occupies the same shape the real content will occupy: same grid, same card count, same approximate heights, same padding. When the real content swaps in, nothing moves, because the space was already correctly sized for it. The skeleton isn't decoration — it's a layout reservation that happens to also communicate "loading" to the user.
// app/dashboard/page.tsx — the real content
export default async function DashboardPage() {
const stats = await getStats(); // three cards, always
return (
<div className="grid grid-cols-3 gap-4">
{stats.map((s) => (
<StatCard key={s.id} {...s} />
))}
</div>
);
}
// app/dashboard/loading.tsx — mirrors that exact structure
export default function Loading() {
return (
<div className="grid grid-cols-3 gap-4" aria-busy="true" aria-live="polite">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-32 rounded-lg bg-gray-200 animate-pulse" />
))}
</div>
);
}
Same grid-cols-3 gap-4, same number of blocks as there are real StatCards, same approximate height (h-32) as a real card actually renders at. When getStats() resolves and the real grid replaces this one, the page doesn't reflow — it's swapping three gray boxes for three filled-in cards at the dimensions those cards already occupied. aria-busy="true" and aria-live="polite" tell assistive tech a loading state is in progress without forcing a disruptive announcement the moment it starts.
loading.tsx shows on every navigation into the segment, not just the first page load. A skeleton that looks fine on a cold load but flashes distractingly on every single in-app navigation between tabs is a real, separate UX problem worth testing for specifically.animate-pulse-style opacity fade is usually enough signal without meaningfully taxing the main thread.aria-busy/aria-live, a screen reader user gets no indication anything is happening during the gap, and then an abrupt content change with no warning once it resolves."Your Lighthouse CLS score is fine on cold load but spikes on client-side navigation between tabs — where do you even start looking?" is a debugging scenario that rewards knowing loading.tsx fires on every navigation, not just the first one, and that a skeleton's dimensions — not just its existence — are what actually prevents the shift; a candidate who just says "add a loading state" without naming the size-matching requirement hasn't actually fixed the CLS number.