No Loading UI in Next.js 16 — Users Watched a White Screen for 8 Seconds
Without loading.js and Suspense boundaries, Next.js 16 blocks the entire page on the slowest data fetch.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- ✓React fundamentals
- ✓Next.js App Router
- ✓Basic understanding of Server Components
- Without loading.js, Next.js waits for ALL data fetches in a page before sending anything to the browser — slowest fetch dictates Time to First Byte
- loading.js creates instant Suspense boundaries at the route segment level, streaming in content as each fetch resolves
- Use granular Suspense boundaries inside layouts to stream independent sections independently
- The React 19 use() hook inside Suspense enables per-component streaming without loading.js boilerplate
- Streaming doesn't eliminate server work — it eliminates waiting for ALL work before sending the first byte
Imagine a restaurant kitchen that doesn't serve anything until every order across all tables is ready. Table 1's salad sits waiting for Table 10's well-done steak. That's your Next.js page without loading UI. Streaming is like serving each table's food as it's ready — the salad comes immediately, the steak arrives when it's done. The customer (user) sees progress instead of staring at an empty table.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
You shipped a dashboard page with three data-fetching components: a user profile (fast, 50ms), a sales chart (500ms), and a monthly report from a legacy API (8 seconds). You open the page. White screen. Eight seconds later, everything renders at once. Your users? They navigated away after 3 seconds.
This is the default behavior in Next.js 16: a page renders only after ALL its data dependencies resolve. The server waits for the slowest fetch, then sends the complete HTML. No progressive loading. No partial rendering. Just a blank screen that suddenly fills.
Next.js has had loading.js for route-level loading states since version 13, and Suspense boundaries for granular streaming since React 18. But most teams either skip loading.js entirely ("it adds complexity") or put a single spinner at the top level (which defeats the purpose). The result: users see either nothing or a generic loading state that doesn't communicate which part of the page is actually loading.
By the end of this guide, you'll know how to use loading.js for instant route-level loading states, Suspense boundaries with fallbacks for per-component streaming, and the new React 19 use() hook to trigger Suspense inside components without loading.tsx boilerplate. Your slow API call won't block your fast components anymore.
How Next.js Streaming Actually Works (It's Not What Most Devs Think)
Streaming in Next.js isn't about sending data chunks over a WebSocket. It's about server-side HTML rendering with Suspense boundaries. Here's the mechanic: when Next.js encounters a Suspense boundary during server-side rendering, it immediately sends the fallback UI (the loading state) as HTML, then continues rendering the rest of the page. When the data inside the boundary resolves, it sends a replacement chunk via an inline <script> tag that swaps the fallback with the real content.
This means streaming requires a persistent HTTP connection. It works with HTTP/1.1 keep-alive, HTTP/2, and HTTP/3. The browser receives HTML progressively — it sees the nav, sidebar, and skeletons immediately, then each section fills in as its data resolves. The alternative (no streaming) sends nothing until the entire page is rendered.
The key insight: streaming doesn't speed up your data fetches. A slow API still takes 8 seconds. But the user sees a loading skeleton within 200ms instead of a white screen for 8 seconds. Perceived performance improvement is massive.
loading.tsx: The Simplest Performance Improvement You're Skipping
loading.tsx is a file convention in Next.js App Router. Place it in any route segment directory (e.g., app/dashboard/loading.tsx) and it automatically wraps the page component in a Suspense boundary. The loading component renders immediately while the page's Server Component fetches its data.
This is the single highest-impact performance change you can make for routes with data fetches. It turns a blank screen into an instant loading state. The implementation is trivial — export a default component that returns JSX. No configuration, no providers, no special API.
loading.tsx applies to the ENTIRE route segment and all its children. If you have app/dashboard/settings/page.tsx, a loading.tsx in app/dashboard/ applies to both the dashboard and settings pages. For more granular control, use in-page Suspense boundaries.
Granular Suspense Boundaries: Streaming Independent Components
loading.tsx is a single Suspense boundary for the entire route. For pages with multiple independent data fetches, you need granular Suspense boundaries inside the page component. Wrap each independently-fetched section in its own <Suspense> with a specific skeleton fallback.
The magic: Suspense boundaries stream independently. If you have a sidebar (fast), main content (medium), and report (slow), the sidebar renders first, the main content second (replacing its fallback), and the report last. Each section replaces its own skeleton without affecting the others.
To trigger Suspense on the server, the component inside the boundary must be an async Server Component or use the React 19 hook. Regular Client Components wrapped in Suspense don't trigger server-side streaming — they only work for client-side lazy loading.use()
The React 19 use() Hook: Triggering Suspense Without async Components
React 19 introduces the use() hook, which unwraps promises inside the render function. When use(promise) is called inside a Suspense boundary, it works like async/await but triggers the Suspense fallback automatically — no async Server Component wrapper needed.
This is useful when you have a Client Component that needs to read data from a promise (e.g., from a cache or context). The component throws a promise (via ), Suspense catches it, renders the fallback, and re-renders the component when the promise resolves.use()
The difference from async/await: can be called conditionally and in loops. It also integrates with React's use()cache() and the new data loading APIs. It's the recommended way to trigger Suspense boundaries in React 19.
Parallel Data Fetching vs. Sequential: Don't Make This Mistake
A common mistake is structuring data fetches sequentially, where one fetch waits for another before starting. This doubles (or triples) the time before any content renders.
``typescript // ❌ Sequential — 8s + 500ms + 50ms = 8.55s const user = await fetchUser() const sales = await fetchSales() const report = await fetchReport() ``
``typescript // ✅ Parallel — max(8s, 500ms, 50ms) = 8s const [user, sales, report] = await Promise.all([ fetchUser(), fetchSales(), fetchReport(), ]) ``
But even with parallel fetches, the page still waits for all three before rendering (without Suspense). The TTFB is 8s regardless. This is why parallel fetches + Suspense boundaries are the winning combination — fetches run in parallel, but each section renders as soon as its specific fetch resolves.
Streaming and SEO: What Googlebot Sees
This is where most developers get confused. Does Googlebot wait for streaming content? Yes — Googlebot renders JavaScript and waits for content to settle before indexing. Streaming HTML is processed like any other HTML: Googlebot reads what it gets and waits for the final state.
However, loading states (spinners, skeletons) are NOT indexed. If your entire page is a loading skeleton for 8 seconds and Googlebot snapshots during that time, it sees an empty page. Googlebot typically waits 5-30 seconds for content, but there's no guarantee it waits for an 8-second slow API.
Best practice: ensure your most important content (headings, main text) is outside Suspense boundaries. Use loading state for secondary content like recommendations, comments, or analytics widgets. Your H1, meta description, and main article content should render without Suspense.
Error Handling Inside Suspense Boundaries
When a fetch fails inside a Suspense boundary, the error propagates to the nearest error boundary. Without an error boundary, the entire page crashes. This is why pairing Suspense with error.js is essential — error.js at the route level catches failures gracefully.
For more granular error handling, wrap individual Suspense boundaries with their own error boundaries. This way, if the slow report API fails, only the report section shows an error state — the rest of the page continues working.
React 19 introduces errorMap as a second argument to , allowing you to handle errors at the component level instead of propagating to an error boundary. This is useful for non-critical data where you want to show a fallback UI instead of an error page.use()
Streaming and Caching: How They Interact
Streaming and caching have a subtle interaction. When a page is fully cached (e.g., with export const dynamic = 'force-static'), the cached HTML is served immediately — no streaming needed. But for dynamic routes that mix cached and uncached data, streaming becomes tricky.
Next.js's partial prerendering (PPR) solves this: it pre-renders static shell components at build time and streams dynamic components at request time. In Next.js 16, PPR is enabled per route by exporting experimental_ppr = true from your layout or page.
Without PPR, the trade-off is: static shell + streaming content means the shell is generated on every request (not cached). For high-traffic routes, consider PPR or moving the static parts to a layout and only streaming the dynamic content.
Comparing Streaming to Other Loading Patterns: SSR, CSR, and ISR
Streaming isn't the only loading strategy. Here's how they compare: Traditional SSR (Server-Side Rendering) waits for all data and sends the complete HTML — zero interactivity until the full page loads, but best SEO. CSR (Client-Side Rendering) sends an empty HTML shell with JavaScript that fetches data on the client — fast initial load, terrible for SEO. ISR (Incremental Static Regeneration) pre-builds pages at build time and revalidates on request — best performance for content that doesn't need real-time data.
Streaming falls between SSR and ISR. It sends the skeleton immediately (like CSR's initial render) but fills content from the server (like SSR). It's ideal for pages that have some fast data and some slow data — dashboards, social feeds, e-commerce product pages with personalized recommendations.
Don't use streaming for pages where ALL data is fast (<200ms) or ALL data is static (use ISR). Use streaming for mixed-speed pages where some content is instant and some requires server-side computation.
Measuring Streaming Performance Correctly
Standard performance metrics need reinterpretation for streaming pages. TTFB (Time to First Byte) becomes misleading — with streaming, the first byte arrives instantly (it's the skeleton HTML). What matters is FCP (First Contentful Paint) and LCP (Largest Contentful Paint).
A well-streamed page: TTFB = 200ms (skeleton HTML arrives), FCP = 400ms (skeleton renders), LCP = 2s (main content arrives and renders). A non-streamed page: TTFB = 6s, FCP = 6s, LCP = 6s.
The key metric for streaming is Time to Interactive (TTI) for each section. Use the Performance API to measure when specific content renders: performance.mark('report-loaded') after the slow report renders.
Lighthouse doesn't fully capture streaming performance. Use WebPageTest's filmstrip view — it shows exactly when each part of the page renders. A well-streamed page shows progressive paint events, not a single flash of content.
A Complete Streaming Architecture for Production
Here's the production setup I use. Every route segment gets a loading.tsx — even if it's just a centered spinner. Inside pages, every component with a data fetch longer than 500ms gets a Suspense boundary with a skeleton that matches its real layout. Pages that combine static and dynamic content use PPR when enabled.
The command center: I run a report once a week that identifies routes where TTFB ≈ FCP (meaning no streaming working). I grep for missing loading.tsx files and Suspense boundaries that are too coarse.
This isn't optional — it's the difference between a page that feels instant (because the skeleton appears immediately) and a page that feels broken (because nothing renders for 8 seconds).
Production Incident: 8-Second White Screen Caused 47% Bounce Rate on SaaS Dashboard
fetch() calls do execute concurrently, but the render output is blocked on the slowest promise.use() hook to trigger streaming automatically.- Every route segment with async data should have a loading.tsx — even if it's just a simple skeleton
- Wrap any component with slow data fetches in Suspense. A 500ms fetch doesn't need it, but anything over 1s does
- loading.tsx only covers the route entry point — nested Suspense boundaries inside the layout handle per-component streaming
- Test with and without loading UI using DevTools' Slow 3G throttling. The difference in user-perceived performance is dramatic
use() hook instead of top-level await.ls app/dashboard/loading.tsx 2>/dev/null || echo 'MISSING'grep -r 'async function' app/dashboard/ | head -5| File | Command / Code | Purpose |
|---|---|---|
| app | export default function DashboardLoading() { | loading.tsx |
| app | export default function DashboardPage() { | Granular Suspense Boundaries |
| components | function DataComponent({ promise }: { promise: Promise }) { | The React 19 use() Hook |
| app | export default async function DashboardPage() { | Parallel Data Fetching vs. Sequential |
| app | function DashboardPage() { | Error Handling Inside Suspense Boundaries |
| app | export const experimental_ppr = true | Streaming and Caching |
| components | export default async function Report() { | Measuring Streaming Performance Correctly |
| app | export const dynamic = 'force-dynamic' | A Complete Streaming Architecture for Production |
Key takeaways
use() hook triggers Suspense from synchronous components, replacing async wrapper componentsInterview Questions on This Topic
How does streaming work in Next.js App Router, and what mechanism allows Suspense boundaries to render independently?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's Next.js. Mark it forged?
6 min read · try the examples if you haven't