React Suspense and Streaming SSR
Traditional SSR waits for all data before sending HTML, so a slow API holds back the entire page. Streaming SSR renders and sends incrementally, while Suspense shows loading first and streams in data later. Learn the mechanics, Suspense's SSR role, Next.js App Router streaming, selective hydration, and unblocked LCP.
Key Points
- Topic Focus: Next.js、React
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
You built an SSR page, deployed it, and opened it: a blank screen for two seconds, then the entire page suddenly appears at once. You assume the network is slow, open the document request in DevTools Network, and find a TTFB (Time to First Byte) of a full 1.8s. But your backend is clearly fast, and template rendering takes only tens of milliseconds. Where is the problem?
It lies in the traditional SSR execution model: it must wait until all data used by the page has returned before it can start generating HTML, then send the complete HTML in the response body all at once. As long as the page contains one slow API, such as a 1.5s third-party API for a homepage's "popular events" module, the page's first byte must wait for that 1.5s even if the navbar and above-the-fold content were ready long ago. One slow component can drag down the entire page.
React 18's answer is Streaming SSR + Suspense: render and send as you go, send the shell and loading states first, then stream in replacements once the data is ready. This article explains the mechanism end to end: where traditional SSR gets stuck, how Streaming SSR turns "wait for everything" into "send what is ready first," Suspense's actual role in SSR, default streaming in the Next.js App Router, how selective hydration keeps slow components from blocking interaction, a practical "layout first + independent Suspense for each section" implementation, and an easy-to-miss SEO trap.
1. The Traditional SSR Bottleneck: Wait for All Data, Send One Big HTML Document
First, establish the traditional SSR execution model. Using the classic Next.js Pages Router as an example, this is how getServerSideProps runs:
// Pages Router 的经典模型
export async function getServerSideProps() {
// 三个数据请求,串行等全部完成
const [user, feed, recommendations] = await Promise.all([
fetchUser(), // 50ms
fetchFeed(), // 100ms
fetchRecommendations() // 1500ms ← 拖垮全页的慢接口
]);
return { props: { user, feed, recommendations } };
}
export default function Page({ user, feed, recommendations }) {
// 只有上面三个全 resolve,这里才会执行
// HTML 一次性生成,一次性发送
return (
<html>...</html>
);
}
The lifecycle of the entire request looks like this:
Client Server
│ │
│ ── GET / ────────────► │
│ │ ┌─ fetchUser() 50ms ┐
│ │ │ fetchFeed() 100ms ├─ Promise.all
│ │ └ fetchRecommend() 1500ms ┘ ← Must wait for the slowest one
│ │
│ (Blank screen... 1.5s+)│ renderToString(...) Generate complete HTML
│ │
│ ◄── Return everything at once ──── │
│ │
│ Browser starts parsing and hydrating │
See the problem? TTFB is completely held hostage by the slowest request. During those 1.5 seconds, users stare at a blank screen even though you already have enough data to render the navigation, user information, and Feed list. Only that damn "recommendations" module is missing.
And that is not all. Traditional SSR has a second problem: HTML is sent as a complete document in one go. The browser must wait for the entire document response before it can begin parsing it. Although modern browsers can parse streams, renderToString is synchronous, so nothing can be sent until it finishes. As a result, both FCP (First Contentful Paint) and LCP (Largest Contentful Paint) are pinned to the same line: "slowest API + full-page rendering."
These are the two structural bottlenecks of traditional SSR:
- The data layer has a slowest-link effect: the readiness time for all page data is
max(each API duration), so a slow API drags down the page. - The delivery layer is all-or-nothing: HTML has no priority or sequence; you cannot "send important content first and fill in slow content later."
The root issue is that the entire page is treated as one atomic unit, with no concept of ordering. Breaking this requires changes at both the protocol and rendering layers: HTML must be sendable in chunks (Streaming), and rendering must be able to "pause and resume" (Suspense).
2. Streaming SSR: Render and Send Incrementally, Breaking Apart the Wait
The core idea of Streaming SSR in one sentence: do not wait for all data to be ready before sending HTML; send the parts that are already certain, then stream the remaining parts when their data is ready.
This relies on an old feature available since HTTP/1.1: chunked transfer encoding. The server can omit Content-Length and split the response body into multiple chunks, sending each chunk as it is generated. The browser begins progressively parsing HTML and building the DOM as it receives chunks. React 18 provides renderToPipeableStream for Node environments and renderToReadableStream for Web Streams, Edge, and Workers, replacing synchronous renderToString.
With streaming rendering, one request becomes this:
Client Server
│ │
│ ── GET / ────────────────────────► │
│ │
│ ◄── chunk 1: page shell ───────── │ Sent immediately!
│ <html><head>…</head><body> │ Navigation, layout, loading placeholders
│ <div>loading…</div> │
│ </body>(not closed yet) │
│ │
│ The browser is already rendering the first screen! │ Meanwhile, wait for data in the background...
│ The user sees navigation + loading │
│ │
│ │ ← fetchFeed() completes at 100ms
│ ◄── chunk 2: stream in Feed ────── │
│ <div class="feed">真实内容</div>│
│ │
│ │ ← fetchUser() completes
│ ◄── chunk 3: User section ──────── │
│ │
│ │ ← fetchRecommend() completes at 1.5s
│ ◄── chunk 4: recommendations + close ─── │
│ Recommendation content + </body> │
│ │
The key change is this: above-the-fold HTML arrives within tens of milliseconds, so users immediately see the layout and loading state instead of waiting idly for 1.5 seconds. Whenever subsequent data becomes ready, a segment of HTML is pushed into the stream to replace its matching placeholder.
React has an elegant mechanism for working with streaming here. Browsers parse HTML in order, so the server cannot "go back" and modify tags it has already sent. How does React "replace" loading content? The answer is placeholders + external script injection:
- When sending the Shell and encountering a Suspense boundary, the server first outputs a placeholder such as
<div id="S:1"><!--$--><!--/$--></div>with a unique ID, containing the fallback content. - When the corresponding data is ready and the real HTML has rendered, React puts that real HTML into the stream as a string in a
<script>. At runtime, it replaces the placeholder div's contents and executes preceding scripts in order to attach events and hydrate. - When the full document stream ends, React outputs commit information for all Suspense boundaries and a closing
</body>.
This mechanism means that Streaming SSR's "streaming replacement" does not modify the HTML source. It uses scripts to dynamically fill in content on the client. What users perceive as progressive appearance is fundamentally the Shell rendering first, followed by later chunks filling the gaps through scripts.
From a performance-metric perspective, Streaming SSR directly improves three important points:
- TTFB drops significantly: from "wait for every API" to "almost immediate."
- FCP happens earlier: once the Shell arrives, the browser can start painting.
- LCP is no longer held back by the slowest component: as long as the LCP element is not inside that slowest Suspense boundary, it can appear early.
The tradeoff is that you need to accept the mental model that "HTML is no longer a complete one-shot document," and the rendering layer needs a way to mark "what can pause first and what should send a placeholder first." That is exactly what Suspense does.
3. Suspense's Role in SSR: Marking Regions That Can Pause
Many people think of Suspense only as loading UI used with React.lazy for code splitting. That is merely its client-side use. In SSR, Suspense is Streaming SSR's slicing tool: it tells React, "data in this region may not be ready; send the fallback first, then stream in this region separately once the data is ready."
The most basic implementation makes this more intuitive than any explanation:
// 一个流式 SSR 的页面结构
function Page() {
return (
<>
<Header /> {/* 同步、立即可用,跟着 Shell 一起发 */}
<Suspense fallback={<FeedSkeleton />}>
<Feed /> {/* 内部 await 数据,数据没好前先发骨架屏 */}
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations /> {/* 慢接口所在的边界 */}
</Suspense>
<Footer /> {/* 同步 */}
</>
);
}
When server rendering reaches <Feed />, if its internal data has not resolved yet, such as because it awaits a promise or reads a suspending data source, that Suspense boundary suspends. React does not block at that point. Instead, it outputs <FeedSkeleton /> as a placeholder and continues rendering the rest of the page. Once the data is ready, React sends the real HTML for <Feed /> as an independent streamed chunk to replace the placeholder.
There is one counterintuitive but critically important point here: a Suspense boundary determines not only what is sent first, but also how failures are isolated. If Recommendations throws during rendering, the error stays at that boundary. You can pair it with an error.tsx in Next.js or place an error boundary beside the fallback. Other parts, including Header, Feed, and Footer, remain unaffected and may already have been sent to users. Compared with traditional SSR, where one component error can make the whole page return 500, this is a qualitative improvement.
So how does a Feed component actually suspend internally? In React 18+ SSR, there are three main sources of suspension:
- Framework-integrated data layers: in Next.js,
fetchwith Server Components oruse()to read a promise suspends while data is unavailable. TanStack Query, SWR, and similar tools can also expose promises to Suspense during SSR. React.lazy+ Suspense: used for component code splitting; the boundary suspends while the component bundle loads, including during SSR.awaitin Server Components: directlyawaiting an asynchronous operation in RSC is naturally a suspension point.
// Next.js App Router 里,Server Component 直接 await
async function Feed() {
// 这个 await 就是挂起点,外层 Suspense 会先发 fallback
const posts = await fetchPosts();
return <PostList posts={posts} />;
}
A common question is: can Suspense be nested? Yes, and nesting is the main way to control streaming granularity. A coarse boundary, such as one Suspense for the entire page, provides little streaming benefit and creates a coarse loading experience. Fine boundaries, such as one per section, maximize parallelism: whichever is ready first can be sent first. But boundaries that are too fine also have side effects, including a fallback for each boundary and deeper DOM structure. You need to balance parallelism gains against maintenance cost. A practical rule is: split by blocks whose data is independent and that can be displayed independently, not by every small component.
Once you understand Suspense as this slicing tool, it becomes clear why the Next.js App Router is "streaming by default."
4. Streaming in the Next.js App Router: Enabled by Default, with loading.tsx as the Boundary
Next.js 13+ App Router turns the mechanism above into default behavior. You do not need to call renderToPipeableStream manually or write Suspense boundaries yourself, though you can still write them. The framework wires it up for you.
The clearest expression of this is the loading.tsx file convention. Put a loading.tsx under any route segment, and Next.js automatically wraps that segment's page.tsx with Suspense, using the component exported by loading.tsx as the fallback:
app/
dashboard/
page.tsx ← 路由内容(可能含慢查询)
loading.tsx ← 自动成为包裹 page 的 Suspense fallback
error.tsx ← 自动成为该段的错误边界
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />;
}
// app/dashboard/page.tsx
export default async function Page() {
// 这里的 await 会挂起整个 page,
// 流式 SSR 期间用户先看到 DashboardSkeleton
const stats = await fetchStats(); // 1.2s
return <Dashboard stats={stats} />;
}
At runtime, this code does the following:
- The request arrives. Next.js immediately starts the streaming response, first outputting the site-wide layout, such as RootLayout and navigation in the Shell, along with
DashboardSkeleton. fetchStats()resolves in the background, taking 1.2 seconds.- When the data is ready, Next.js sends the real
<Dashboard />as a streamed chunk to replace the skeleton.
You did not write a single line of Suspense, yet you have streaming. This is an easily overlooked improvement of the App Router over the Pages Router. The Pages Router's getServerSideProps blocks synchronously, as shown in section 1, while the App Router moves data fetching into the component tree, making it naturally suspendable and streamable.
But "streaming by default" does not mean "automatically optimal." Next.js's automatic Suspense boundary granularity is route-segment level: one loading.tsx wraps an entire page.tsx. If your page contains multiple independent sections, one slow query can still hold the entire page's real content, not just a skeleton, until it becomes ready and is replaced in one streamed operation. To stream each section independently, you need to add Suspense boundaries manually:
// app/dashboard/page.tsx —— 手动细分 Suspense,最大化并行
import { Suspense } from "react";
export default function Page() {
return (
<>
{/* 路由级 loading.tsx 仍在,但 page 内部进一步切片 */}
<Suspense fallback={<RevenueSkeleton />}>
<RevenueChart /> {/* 内部 await 慢查询 */}
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed /> {/* 另一个独立慢查询 */}
</Suspense>
<Suspense fallback={<TeamSkeleton />}>
<TeamList /> {/* 又一个 */}
</Suspense>
</>
);
}
This lets all three sections suspend independently and stream independently. If RevenueChart takes 1.2s, ActivityFeed takes 0.8s, and TeamList takes 2s, users see the activity feed at 0.8s, the chart at 1.2s, and the team list at 2s, instead of waiting idly for 2s before seeing everything at once. This is the core idea behind the practical example in the next section.
There is another easy pitfall: the Suspense fallback is sent with the Shell too, so do not make the fallback a slow component as well. A common anti-pattern is making a fallback "fetch data again before rendering the skeleton." That reverses the purpose. A fallback must be synchronous, lightweight, static UI such as a skeleton, placeholder copy, or spinner.
5. Selective Hydration: Slow Components No Longer Block Interaction
Streaming SSR solves the problem of "sending HTML," but once the page reaches the browser, there is a second hurdle: hydration. The server sends static HTML, and for it to become an interactive React application, the browser must download JavaScript, execute it, and attach event listeners to the DOM. Traditionally this step is blocking and all-at-once: React must hydrate the entire tree in order. A user may click a button with no response because React has not hydrated that point yet, or a slow component's hydration can hold up everything after it.
React 18's Selective Hydration, together with concurrent features, fundamentally changes this:
- Hydration is no longer all-or-nothing: React splits the tree into multiple hydration tasks, with Suspense boundaries naturally acting as split points. Tasks can interleave and yield the main thread for browser input and painting.
- User interaction has priority: if a user clicks a component that has not finished hydrating, React moves that component's hydration task to the front to respond immediately, even if other hydration tasks are still queued.
- Slow components no longer block everything: with traditional hydration, React can get stuck when it encounters a component still waiting for data and cannot continue hydrating later components until that data is ready. Selective Hydration lets React skip that component, hydrate already-ready parts later in the tree first, then return to the slow component.
Consider an intuitive example. Your page has three regions: Header, which contains fast navigation; HeavyChart, a chart component with a large dependency and slow hydration; and Comments, a normal comments section. Traditional hydration looks like this:
下载 JS → hydrate Header → hydrate HeavyChart(卡 800ms) → hydrate Comments
用户在这 800ms 点哪里都没反应
Selective Hydration looks like this:
下载 JS → 同时开始 hydrate Header / HeavyChart / Comments(交替、可中断)
用户在第 200ms 点了 Comments → React 立刻优先 hydrate Comments
Comments 立即可交互,HeavyChart 慢慢来不挡道
This turns the user experience during hydration from "all or nothing" into "prioritized on demand." The cost of a slow component is confined to its own region instead of spilling outward. Combined with Streaming SSR, the complete experience is: the Shell arrives quickly for early FCP, data streams in for early LCP, and each region hydrates independently with interaction priority, producing earlier TTI without being held back by slow regions.
It is worth noting that Selective Hydration requires a concurrent root in React 18+, namely createRoot; the Next.js App Router provides this by default. You cannot get it with the old renderToString + hydrate setup. That is why adopting React 18 or the App Router is itself a performance improvement: it is not just new APIs, but a different underlying scheduling model.
6. Improving LCP: Critical Content Does Not Wait for the Slowest API
Among performance metrics, LCP (Largest Contentful Paint) has one of the strongest effects on perceived performance. It measures when the page's primary content becomes visible. With traditional SSR, the LCP element, typically a large above-the-fold image, headline, or main content region, cannot appear until the slowest API returns and all page HTML has been generated. Streaming SSR splits that dependency chain apart.
The logic is simple: as long as the LCP element is not inside the slowest Suspense boundary, it can arrive early. Its HTML is sent first with the Shell or an earlier chunk, so the browser paints it immediately.
Consider this comparison. Suppose the page's LCP element is an above-the-fold product list that depends on fetchProducts() and takes 200ms, while the page also contains a comments section using fetchComments() that takes 1.5s:
Traditional SSR:
TTFB: ───────────────────────── max(200ms, 1.5s) = 1.5s ──► Send the whole page HTML
LCP: ───────────────────────────────────────────── 1.5s+ ──► Product list becomes visible
(Slow comments unnecessarily delay LCP by 1.3s)
Streaming SSR (products and comments each have a Suspense boundary):
TTFB: ──► Immediately send the Shell + two skeletons within tens of milliseconds
LCP: ────── 200ms ──► Real product-list HTML streams in and the browser paints it ✅
Comments: ────────────────────── 1.5s ──► Comments stream in later without affecting LCP
LCP drops from 1.5s+ to 200ms. The key is to split out slow, non-critical pieces such as comments, recommendations, ads, and third-party widgets with Suspense, allowing the boundary containing above-the-fold critical content and the LCP element to resolve as quickly as possible.
This gives us a highly practical principle for splitting Suspense boundaries: put above-the-fold critical content, such as the Hero, primary list, and main image, in a fast boundary of its own, and put slow secondary content in separate boundaries. Do not let a slow API share a Suspense boundary with an LCP element, or you effectively preserve traditional SSR's slowest-link effect.
However, LCP has a measurement pitfall in streaming scenarios. The browser may update the LCP timestamp whenever a new largest content element appears. If it first paints a large skeleton, which may count as LCP, and then replaces it with real content, the timestamp updates again. The final LCP is the last largest content paint during streaming. Therefore, do not make skeletons too large or too screen-filling. A giant skeleton that fills the first viewport may itself be recorded as LCP and pollute the metric. Making skeletons clearly smaller than real content, or using low-priority placeholders, can reduce this interference.
7. In Practice: Layout First, Then Independently Stream Each Section with Suspense
Now combine the preceding ideas into a complete practical example. The requirement is a content site's homepage with top navigation, an above-the-fold featured item (Hero, critical), an article list (critical), trending tags (secondary and slow), and recommended authors (secondary and slow). The goal is for above-the-fold critical content to arrive quickly and secondary content to stream in later.
First, at the data layer, expose slow API sources explicitly through suspendable Server Components:
// app/(home)/_components/Hero.tsx
// 关键内容,依赖一个快的接口
async function Hero() {
const featured = await fetchFeaturedPost(); // 100ms,快
return (
<section className="hero">
<h1>{featured.title}</h1>
<p>{featured.excerpt}</p>
</section>
);
}
// app/(home)/_components/ArticleList.tsx
// 关键内容,主列表
async function ArticleList() {
const articles = await fetchArticles(); // 200ms
return <ArticleGrid articles={articles} />;
}
// app/(home)/_components/TrendingTags.tsx
// 次要内容,慢接口
async function TrendingTags() {
const tags = await fetchTrendingTags(); // 1.2s,慢
return <TagCloud tags={tags} />;
}
// app/(home)/_components/RecommendedAuthors.tsx
// 次要内容,更慢
async function RecommendedAuthors() {
const authors = await fetchRecommendedAuthors(); // 1.8s,最慢
return <AuthorCarousel authors={authors} />;
}
At the page layer, split based on "critical vs. secondary." Use lightweight fallbacks for critical content, or even no fallback and stream it directly, while giving each secondary region its own Suspense boundary:
// app/(home)/page.tsx
import { Suspense } from "react";
import Hero, { HeroSkeleton } from "./_components/Hero";
import ArticleList, { ArticleListSkeleton } from "./_components/ArticleList";
import TrendingTags, { TrendingTagsSkeleton } from "./_components/TrendingTags";
import RecommendedAuthors, { RecommendedAuthorsSkeleton } from "./_components/RecommendedAuthors";
export default function HomePage() {
return (
<div className="home">
{/* 关键路径:首屏内容,给轻量骨架,目标 LCP 在这里 */}
<Suspense fallback={<HeroSkeleton />}>
<Hero />
</Suspense>
<Suspense fallback={<ArticleListSkeleton />}>
<ArticleList />
</Suspense>
{/* 次要内容:各自独立 Suspense,慢但不拖累首屏 */}
<Suspense fallback={<TrendingTagsSkeleton />}>
<TrendingTags />
</Suspense>
<Suspense fallback={<RecommendedAuthorsSkeleton />}>
<RecommendedAuthors />
</Suspense>
</div>
);
}
When it runs, the timeline looks like this, with numbers used only as an illustration:
t=0ms Request arrives
t=20ms Streaming starts: send the Shell (HTML framework, global CSS links, navigation, Footer)
+ four skeletons (HeroSkeleton / ArticleListSkeleton / TrendingTagsSkeleton / RecommendedAuthorsSkeleton)
→ The user immediately sees the full layout + skeletons; FCP is achieved
t=120ms Hero data is ready → stream in real Hero HTML → browser paints the above-the-fold feature
t=220ms ArticleList data is ready → stream in the article list
→ At this point, all above-the-fold critical content (Hero + list) is visible; LCP is achieved ✅
t=1220ms TrendingTags data is ready → stream in the tag cloud
t=1820ms RecommendedAuthors data is ready → stream in the author carousel + close the document
t=JavaScript downloaded → Selective Hydration: navigation and Hero become interactive immediately;
slow regions hydrate independently and do not block each other; the region a user clicks hydrates first.
Compared with traditional SSR, which "waits 1.8s before sending the first byte," this structure brings LCP down from 1.8s+ to roughly 220ms, while all content arrives within 1.8s and each region can become interactive independently.
Several practical lessons are worth remembering:
- Skeletons should resemble the real content, creating the expectation that it is about to arrive rather than showing a lonely spinner. A structured skeleton that fills the layout feels far better than a blank screen plus a spinning indicator.
- The critical content data source must actually be fast. Streaming SSR is not magic. If the Hero API itself takes 2s, users still wait 2s to see the Hero no matter how attractive the skeleton is. Streaming optimizes content that is being delayed by other content; it cannot optimize a critical path that is slow by itself. Critical paths still need caching, such as ISR or
revalidate, read replicas, prefetching, and other techniques. - Put slow third-party components, including ads, comments, and social buttons, in their own Suspense boundaries. They are the external risks most likely to drag down the first screen. Once isolated, being slow or failing does not affect the main content.
- Use
loading.tsxtogether with handwritten Suspense. Route-levelloading.tsxprovides a fallback to prevent blank space if the whole page is not ready, while handwritten Suspense within the page provides fine-grained streaming. They do not conflict.
8. Pitfalls: SEO, Error Boundaries, and Things Nobody Warns You About
Streaming SSR is not a free lunch. It introduces three pitfalls that must be addressed seriously, starting with SEO.
Pitfall 1: Search engines see HTML during the streaming process, so confirm they can handle it. Modern crawlers such as Googlebot are Chromium-based and can execute JavaScript and process streamed HTML and script-injected content. In theory, they eventually see all chunks sent later. But there are several gaps between theory and reality:
- Not every crawler is like Googlebot. Some older or lightweight crawlers, some social-sharing crawlers that fetch OG tags, and some monitoring services may not execute JavaScript and only capture the "first response snapshot." They may see only the Shell and skeletons, not content filled in by later streams.
- Crawling has budgets and timeouts. Crawlers will not wait forever. If data for a Suspense boundary is especially slow and its streamed chunk arrives late, the crawler may finish rendering before it arrives.
- Critical SEO elements, including title, meta description, OG tags, and structured data, must never be placed inside a Suspense boundary that can suspend. They should be determined during the Shell stage. Next.js's Metadata API waits for and statically generates these tags by default, but manually placing metadata inside a suspending Server Component creates risk.
A practical recommendation is: for SEO-critical pages, such as landing pages and detail pages, avoid making the core content depend on streamed-in content whenever possible. The parts you stream, such as comments, recommendations, and related content, should be content whose omission by crawlers is harmless. Use Google's URL Inspection / Search Console's "Test Live URL" to verify what crawlers ultimately see instead of relying on guesswork.
Pitfall 2: the timing of useEffect changes. With traditional SSR, useEffect runs after hydration completes and all components are present. With streaming, some components arrive later, so their useEffect runs only after they are streamed in and hydrated. That means logic that depends on "all content being ready," such as measuring the full-page height or initializing a library across regions, may run too early. Client components need to handle the possibility that they mount later, or use useSyncExternalStore to receive data.
Pitfall 3: error-handling boundaries must be complete. As mentioned earlier, Suspense boundaries can isolate failures, but only if you configure error boundaries. In the Next.js App Router, error.tsx fills this role: place error.tsx under a segment that may suspend, and it renders when that segment throws instead of turning the entire page into a 500 response. If you omit error.tsx, an exception in a secondary component can bubble to the root and bring down the whole streaming response, which is worse than traditional SSR.
Pitfall 4: interaction between streaming and caching. In Next.js, fetch cache options such as cache: 'force-cache', revalidate, and no-store determine whether a data source is fast. If you mark data that should be static as no-store and its API is slow, every request suspends and affects streaming. Cache anything cacheable, and isolate dynamic slow APIs in secondary Suspense boundaries.
Pitfall 5: recalibrate logging and monitoring for streaming. Traditional SSR has one request and one response, so logging a single document duration is enough. With streaming, one request contains multiple chunks; TTFB, each chunk's time, and total duration are distinct. Monitoring should instrument them separately, otherwise a reported "response time" can mislead you. For example, you may record only the first chunk and think it was fast while the user actually waited 2s for content.
Summary
- Traditional SSR has two major bottlenecks: the data slowest-link effect and one-shot delivery of complete HTML. One slow API holds the entire page's TTFB hostage, slow content delays fast content, HTML has no priority, and FCP/LCP are both pinned to "slowest API + full-page rendering."
- Streaming SSR uses HTTP chunked transfer to render and send incrementally. It immediately sends the Shell and skeletons, then streams later chunks when data is ready, using placeholder divs and script injection to replace fallbacks. TTFB drops from "wait for all data" to "almost immediate."
- Suspense is Streaming SSR's slicing tool. It marks regions that can pause, sends a fallback before data is ready, then streams the region in separately once ready. It also isolates errors at boundaries. In the Next.js App Router,
loading.tsxis an automatic Suspense boundary anderror.tsxis the matching error boundary. - Selective Hydration prevents slow components from blocking interaction. React 18+ breaks hydration into interleavable, interruptible tasks. Whichever region a user clicks gets priority hydration, so the cost of slow regions stays local rather than spilling outward. It requires a concurrent root.
- The key to improving LCP is to put the LCP element in a fast boundary of its own and split slow secondary content into independent boundaries. Do not let slow APIs share Suspense with critical above-the-fold content, or you retain the slowest-link effect. Do not make skeletons too large, or they may be recorded as LCP and pollute your metric.
Key takeaway: traditional SSR is "wait until every dish is ready before serving," while Streaming SSR is "put out the tablecloth and cold dishes first, then serve each hot dish when it is ready". It turns "wait for everything" into "send what can be sent first," so slow APIs can no longer drag down the first screen. Suspense is the knife that divides the dishes; how well you divide them directly determines whether your LCP is held hostage by the slowest API or waits only for the most critical region.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
How Should a Technical Community Design Its Tag System and Recommendation Feed?
Centered on independent development, Next.js technology trends, engineering practices, and the indie developer perspective.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.