EmpireUI
Get Pro
← Blog7 min read#nextjs#react-suspense#streaming-ssr

Next.js Streaming: Faster TTFB with Suspense and Loading States

Next.js streaming with Suspense cuts your TTFB dramatically. Here's how loading states, React Server Components, and partial hydration actually work together.

Server rack with blinking lights representing fast streaming data transfer and low latency

Why Your Next.js App Feels Slow Before a Single Byte Renders

Honestly, most Next.js performance problems aren't in your components — they're in the gap between the user hitting Enter and the browser getting anything useful back. That gap is your TTFB. Time to First Byte. And if you're still using the traditional SSR pattern where the server waits for every data fetch before flushing HTML, you're leaving real latency on the table.

With Next.js 14 and 15, streaming changes that equation. Instead of blocking the entire response on your slowest data dependency, you can flush the shell of your page immediately and stream in the heavier parts as they resolve. Users see something in under 100ms rather than staring at a blank tab for two seconds.

This isn't magic. It's HTTP chunked transfer encoding combined with React's streaming renderer. But the developer experience on top of it — Suspense boundaries, the loading.tsx file convention, and Server Components — makes it genuinely practical to implement without restructuring your entire app.

How React Suspense Boundaries Control What Streams When

Suspense has been in React since 16.6, but for years it only worked with React.lazy for code splitting. With React 18's concurrent features and Next.js 13+ App Router, Suspense boundaries now also gate async Server Components. That's the unlock.

When the server encounters a Suspense boundary wrapping an async component, it doesn't block. It renders the fallback immediately, flushes it to the client, and continues resolving the async work in the background. Once the data arrives, the resolved content streams down and React swaps it in on the client — no full page reload, no layout shift if you size your fallbacks correctly.

The mental model is: each Suspense boundary is an independent loading slot. You can have five different slots on one page, each resolving at different times. A fast user stats widget arrives in 40ms. A slower analytics chart arrives in 800ms. They stream independently. That's genuinely different from waiting 800ms for everything.

The loading.tsx Convention and Route-Level Streaming

The simplest entry point into Next.js streaming is the loading.tsx file. Drop one in any App Router route segment and Next.js automatically wraps that segment's page.tsx in a Suspense boundary, using your loading file as the fallback. Zero configuration.

What you put in loading.tsx matters a lot for perceived performance. A skeleton that matches the rough layout of your actual content prevents the jarring shift from empty to full. A spinning circle in the center of the screen doesn't. Match the height, approximate the column structure, use a shimmer animation — your users will feel the difference even when they can't articulate why.

One thing to keep in mind: loading.tsx covers the entire route segment. If you want finer-grained control — say, streaming a comments section independently of the main article body — you'll need explicit <Suspense> wrappers inside your page component. The file convention and manual boundaries work together, they're not mutually exclusive.

Building a Streaming Page Component with Explicit Suspense

Here's what a real streaming layout looks like in practice. We've got a product page where the main product info is fast (cached), but the recommendation engine is slow (uncached external API). We don't want to block the whole page on recommendations.

import { Suspense } from 'react'
import { ProductHero } from '@/components/ProductHero'
import { RecommendationRail } from '@/components/RecommendationRail'
import { RecommendationSkeleton } from '@/components/RecommendationSkeleton'

// This is a Server Component — no 'use client' directive
export default async function ProductPage({
  params,
}: {
  params: { slug: string }
}) {
  // This fetch is fast — hits a CDN-cached endpoint
  const product = await fetch(
    `https://api.example.com/products/${params.slug}`,
    { next: { revalidate: 3600 } }
  ).then(r => r.json())

  return (
    <main className="mx-auto max-w-6xl px-4 py-8">
      {/* Renders immediately — no Suspense needed */}
      <ProductHero product={product} />

      {/* Streams in when the slow fetch resolves */}
      <section className="mt-12">
        <h2 className="mb-6 text-xl font-semibold">You might also like</h2>
        <Suspense fallback={<RecommendationSkeleton count={4} />}>
          <RecommendationRail productId={product.id} />
        </Suspense>
      </section>
    </main>
  )
}

The RecommendationRail component is itself an async Server Component that does its own slow fetch internally. Next.js handles the rest. The skeleton renders instantly, the rail streams in when ready. You can pair this with Empire UI skeleton components or roll your own — see the react-performance-guide for patterns on keeping fallbacks lightweight.

Skeleton Components That Actually Match Your Content

The quality of your loading state determines whether streaming feels fast or just feels broken. A white flash followed by an abrupt content pop is arguably worse UX than a single long wait, because it creates two moments of visual disruption instead of one.

For a recommendation rail with four cards at 280px wide and an 8px gap, your skeleton should mirror that. Don't approximate — measure. Use the same grid columns, the same aspect ratio on the image placeholder, the same line heights on the text blocks. Here's a minimal shimmer skeleton in Tailwind:

function RecommendationSkeleton({ count }: { count: number }) {
  return (
    <div className="grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-2">
      {Array.from({ length: count }).map((_, i) => (
        <div key={i} className="rounded-xl overflow-hidden">
          <div className="aspect-[4/3] animate-pulse bg-zinc-800" />
          <div className="mt-3 space-y-2 px-1">
            <div className="h-3 w-3/4 animate-pulse rounded bg-zinc-800" />
            <div className="h-3 w-1/2 animate-pulse rounded bg-zinc-800" />
          </div>
        </div>
      ))}
    </div>
  )
}

The animate-pulse utility in Tailwind v4.0.2 uses a simple opacity keyframe animation — nothing expensive. You can get a more polished shimmer with a moving gradient, but for most cases the pulse is sufficient and has a smaller CSS footprint. If you want glassmorphism-style skeletons, check out what is glassmorphism for the backdrop-filter approach.

Error Boundaries: What Happens When a Streamed Chunk Fails

Here's a question developers don't ask until it bites them in production: what happens if the async component behind a Suspense boundary throws? The short answer is that without an error boundary, the error propagates up and potentially crashes the whole page — even content that already streamed successfully.

Wrap your Suspense boundaries with an error.tsx file at the route level, or use a client-side ErrorBoundary component for more granular control. The error.tsx file convention in Next.js App Router automatically wraps segments in an error boundary, similar to how loading.tsx adds Suspense. For nested streaming components, you'll often want both in the same folder.

One practical pattern: give non-critical sections (recommendations, ads, sidebar widgets) their own error boundaries with graceful fallbacks — maybe just hide that section entirely. Don't let a broken third-party widget tank your core content. The product page should still work even if the recommendations API is down. This connects to react-toast-notifications patterns — sometimes the right response to a failed stream chunk is a quiet toast, not a broken UI.

Measuring TTFB and Streaming Impact in Real Tools

You can't improve what you don't measure. Open Chrome DevTools, go to the Network tab, and look at a full-page load for your route. The TTFB is shown in the Timing breakdown of the document request. With traditional SSR on a slow data dependency, you might see 600-1200ms before the first byte. With streaming, that same request might return its first chunk in 80-120ms.

The Web Vitals you're optimizing here are LCP (Largest Contentful Paint) and FCP (First Contentful Paint). Streaming the shell immediately improves FCP almost always. LCP depends on whether the largest element on screen is in the shell or behind a Suspense boundary. If your hero image is in a streamed chunk, LCP won't improve — move it above the fold and outside any Suspense wrapper.

Use next build && next start with production mode for meaningful numbers. Dev mode disables optimizations and caching, so the timings are misleading. Vercel's Speed Insights dashboard also breaks down streaming vs blocking time per route if you're deployed there. The differences are usually striking enough that you won't need convincing to ship it.

Integrating Streaming with Client Components and Empire UI

Streaming is a Server Component feature — client components don't participate in server-side streaming directly. But that doesn't mean your interactive UI is stuck. The pattern is: wrap async server fetching in Server Components, then pass the resolved data as props to Client Components for interactivity.

Empire UI components like modals, dropdowns, and notification toasts are Client Components (they need browser APIs and event handlers). You can still benefit from streaming by ensuring the data they depend on is fetched in a parent Server Component, streamed down, and then handed off. The client component hydrates immediately once it arrives. For theme-aware components, make sure your theme toggle setup works server-side so your streamed HTML already has the right class names — flickering dark/light mode on streamed content is a common gotcha.

The overall architecture that works well: App Router layout files for your persistent shell (nav, sidebar, footer), async Server Components per route for data fetching, Suspense boundaries around the slow parts, Client Components for anything interactive. It's not complicated once you've done it once. The streaming behavior is largely automatic — your job is placing the Suspense boundaries where they give you the most visible improvement.

FAQ

Does Next.js streaming work with Pages Router or only App Router?

Only App Router. The Pages Router uses getServerSideProps which blocks the entire response before flushing HTML. Streaming requires the App Router's file-based conventions (loading.tsx, async Server Components) and React 18's streaming renderer. If you're on Pages Router, you'd need to migrate the routes you want to stream.

Can I use Suspense with data fetching libraries like SWR or React Query in streaming?

On the server, you don't need SWR or React Query — async Server Components fetch directly with fetch() or an ORM. SWR and React Query are client-side tools. That said, you can use React Query's useSuspenseQuery in a Client Component if that component is wrapped in a Suspense boundary client-side. The streaming part is still handled server-side through the Server Component tree.

Why does my loading.tsx not show when navigating between routes client-side?

Client-side navigation in Next.js App Router doesn't trigger loading.tsx by default for routes that have already been prefetched. The Suspense boundary only activates during the server render of the initial request, or when navigating to a route that wasn't prefetched. You can disable prefetching on a Link with prefetch={false} to test this. For showing loading state on client navigations, look into using useTransition or startTransition from React 18.

Does streaming affect SEO? Will Googlebot see the streamed content?

Yes, Googlebot renders streamed content. Modern Googlebot uses a rendering pipeline that waits for JavaScript execution and streamed content before indexing. That said, content that arrives very late (after several seconds) may have lower indexing priority. Keep critical SEO content — titles, main body text, structured data — in the shell that streams first, not behind slow Suspense boundaries.

How do I prevent layout shift when the Suspense fallback swaps for real content?

Give your fallback skeleton the same dimensions as the content it replaces. Use fixed heights on image placeholders (aspect-[16/9] or explicit h-48), match the number of text lines, and use the same grid layout. If the real content's height is dynamic (varies per user), you'll get some shift — this is a known tradeoff. One approach is to over-estimate the skeleton height slightly so the swap causes the page to shrink rather than push content down, which reads as less jarring.

What's the difference between streaming and static generation? Should I use both?

Static generation (SSG) pre-renders pages at build time — zero runtime server cost, fastest possible TTFB. Streaming is for pages that require runtime data (personalization, live inventory, user-specific content) where SSG isn't possible. Yes, use both: statically generate what you can, stream the parts that need runtime data. Next.js partial pre-rendering (PPR, introduced in 14.x) formalizes this — it statically generates the shell and streams the dynamic slots. It's still experimental but worth watching.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Next.js Performance Checklist 2026: Every Optimization, RankedReact cache() Function: Deduplication for Server ComponentsNext.js Bundle Analyzer: Finding and Fixing Size IssuesNext.js App Router vs Pages Router: Which to Use in 2026