Words that inspire, ideas that spark,
stories that stay
How Next.js Streaming Improves Time-to-Interactive at Scale
Harsh Raj
Wednesday, September 9th, 20266 min read
If you’ve built web applications long enough, you've certainly hit the point where server-side-rendering stops helping and starts hurting.
This usually starts the same way. You build a beautiful dashboard in Next.js with server-side-rendering and in development everything feels instant and blazingly fast. But as the app grows with personalized feeds, analytics, third-party api calls, permission checks, etc, the traditional SSR starts working against you.
Run a performance audit at this point and the numbers are brutal. Either the users keep staring at blank screen while data loads server-side, or the page paints but stays non-interactive (website doesn't respond to user interactions like button clicks)
To understand why this happens, we have to look at the flaw hidden inside traditional SSR.
1. The Problem with traditional SSR
In traditional SSR, the whole response waits on a single sequence of steps. When a page is requested, the server runs through these steps in sequence:
1. Fetch all data required by every component on the page.
2. Render the entire HTML document on the server.
3. Send the complete HTML payload back to the browser.
4. Download and execute the JavaScript bundle on the client to hydrate the DOM and make it interactive
The catch is in step 3: it can't start until the slowest fetch in step 1 finishes
So if 95% of your page's data resolves in 50ms, but one component is waiting on a 2-second database query, the entire response gets blocked on the server. Not just that component, the whole page. The user gets no HTML, no CSS, no visual feedback, for the full two seconds, even though almost everything they're about to see was ready almost instantly.
Notice how waiting for server response (TTFB) takes 2.17 seconds , while actual content download takes only 5.84ms. Because traditional SSR waits for all data fetches to complete on the server before sending a response, the browser sits completely idle for over two seconds. ALT
Why does the page still not respond?
Even after that 2 second wait is over and the browser has the HTML, the page still isn't usable.
It's basically a screenshot of the page, nothing on the page is responsive yet. No dropdown opens, no button clicks, no form submission, nothing happens until React hydrates the page.
By default React doesn't hydrate in chunks, it happens in one big step. React pulls in the whole JS bundle, builds its internal fibre tree, and attaches event listeners on every DOM node at once.
For small pages this is fine and not even noticeable. But once you've got thousands of DOM nodes and a JS bundle of any decent size, that single hydration step stops being instant. It becomes a long task that blocks the main thread. The HTML is right there, fully visible but the user still can’t click anything.
The page can look fully loaded for a few seconds before it’s actually interactive. Clicks during this window won't register as hydration hasn’t attached the event listeners yet. ALT
2. Enter React Server Components & HTML streaming
So what is the solution for this? Well, Next.js solves it with two core concepts working together: React Server Components (RSC) and HTML streaming.
Instead of waiting on every data fetch before sending anything, React lets you break the page into small, independent pieces. Server Components execute exclusively on the server, generating raw HTML without inflating your client JavaScript bundle size.
<Suspense> is what makes it actually useful. When you wrap a slow component in it, you’re telling Next.js not to wait for this component. Send everything else the moment it’s ready, show a placeholder where the slow component goes, and stream in the real content as soon as the slow component is ready.
// app/dashboard/page.tsx
import { Suspense } from 'react';
import FastUserProfile from './FastUserProfile';
import SlowAnalytics from './SlowAnalytics';
import AnalyticsSkeleton from './AnalyticsSkeleton';
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
{/* 1. Fast profile data renders and streams immediately */}
<FastUserProfile />
{/* 2. Slow component is isolated inside Suspense */}
<Suspense fallback={<AnalyticsSkeleton />}>
<SlowAnalytics />
</Suspense>
</main>
);
}
Here is what happens under the hood:
5. Immediate Stream: The server immediately serves an HTTP 200 response containing the HTML, static CSS, fast component markup ( <FastUserProfile /> ) and an inline template fallback ( <AnalyticsSkeleton /> ).
6. Waiting: The browser starts painting the page layout instantly. Meanwhile the server continues processing the <SlowAnalytics /> in the background.
7. The Second Chunk: The moment SlowAnalytics resolves, Next.js sends a second HTML chunk via the same continuous HTTP connection. This chunk sends the actual markup alongside an inline <script> that replaces the loading skeleton with the actual component.
Notice how waiting for server response (TTFB) drops from 2.17 seconds to 98.47ms , while content download takes 2.05 seconds. Because Next.js streaming does not wait for all data fetches to complete before sending a response, the page loads instantly while the slow component streams in the background. ALT
3. Selective Hydration and Unblocking the Main Thread
Getting HTML to the browser quickly solves the visual side of the problem (FCP) as the page shows up sooner, but it is only half of the fix. If a user tries to click a button while a huge JavaScript bundle is still executing, the page still freezes.
As previously discussed, traditional React SSR hydrates the page at once, which is problematic on a big enough page.
Next.js gets around this with Selective Hydration.
Here is what happens under the hood:
8. Hydrating Chunks: As each HTML chunk streams in, React doesn't wait for the full page's JavaScript to finish downloading. It hydrates that chunk the moment its HTML and matching JS are both available.
9. Staying Responsive: Instead of hydrating everything at once, React breaks the work into small units and hands control back to the browser between them. This allows clicks, scrolls, and keypresses to actually get processed.
10. Interruption: If a user clicks on a component that hasn't hydrated yet, React pauses whatever background hydration it was running and jumps in to hydrate the clicked component first.
With Next.js selective hydration, hydration is broken into small, scheduled tasks, giving the main thread room to respond to user input instantly. ALT
4. Core Web Vitals Summary:
Here are the metrics we are focusing on to measure network timing and main-thread work:
● TTFB (Time to First Byte): How long before the server sends anything back.
● FCP (First Contentful Paint): When the browser actually renders something on screen.
● FCP (First Contentful Paint): When the first real content shows up on screen.
● TBT (Total Blocking Time): How long the main thread stays locked by long JavaScript tasks.
● TTI (Time to Interactive): When the page becomes fully responsive to user input (clicks, scrolls, and typing).
● CLS (Cumulative Layout Shift): How much visible content unexpectedly moves around as the page loads.
|
Metric |
Traditional SSR |
Streaming + Selective Hydration |
|
TTFB |
Slow, as it is blocked by the slowest data fetch on the page. The browser gets nothing until every query resolves. |
Fast, since the server sends HTML before slow backend data is ready. |
|
FCP |
Delayed until the entire page, including heavy components, finishes rendering server-side. |
Instant, since the layout and fast components paint immediately, with skeletons in place for pending data. |
|
LCP |
Delayed along with everything else, since the largest element is stuck behind the same blocking request. |
Fast if LCP renders outside the Suspense boundary, but Delayed if accidently wrapped in a slow Suspense chunk. |
|
TBT |
High during heavy hydration passes, since one big JavaScript bundle executes in a single, blocking main-thread task. |
Lower, since hydration splits into smaller units, giving the main thread room to breathe between tasks. |
|
TTI |
Late, as it is tied to both the slowest fetch and the full hydration finishing together. |
Earlier, since individual sections become interactive as soon as their own chunk hydrates, without waiting on the rest. |
|
CLS |
Stable, since the whole page (including final content) renders on server before sending anything to the browser. |
Unstable, as the loader skeleton needs to match incoming content, pixel-by-pixel. |
5. Common Mistakes & How to Avoid Them:
1. Nesting <Suspense> inside each other
Nesting one slow <Suspense> inside another brings the original problem back again, this time on the server isntead of the client
<Suspense fallback={<SkeletonA />}>
<SlowComponentA /> {/* takes 1.5s */}
<Suspense fallback={<SkeletonB />}>
<SlowComponentB /> {/* waits for A first, then takes another 1.5s — 3s total */}
</Suspense>
</Suspense>
Since SlowComponentB is nested inside SlowComponentA , it can't even start until A finishes. You end up getting back to back delays.
B doesn't even start until A finishes, so the delays stack to 3.0s. ALT
The fix: Put independent components side by side instead of nested inside each other. That lets Next.js fetch both at the same time and stream whichever finishes first.
<Suspense fallback={<SkeletonA />}>
<SlowComponentA />
</Suspense>
<Suspense fallback={<SkeletonB />}>
<SlowComponentB />
</Suspense>
Now both the components are wrapped in completely independent <Suspense> and won’t interfere in the loading of the other sibling component.
A and B run at the same time, so the total is just whichever one takes longer. ALT
2. Hiding most important content behind a slow boundary
Your LCP score depends on when the largest visible element on the page (usually its the hero section, main cover image, etc). If that element is wrapped in a <Suspense> boundary that depends on a slow fetch, the browser can't mark the page as loaded until that fetch resolves, no matter how fast everything else on the page was.
<Suspense fallback={<HeroSkeleton />}>
<HeroImage /> {/* LCP element now waits on this fetch */}
</Suspense>
The fix: Keep your primary above-the-fold content like hero, main images, anything the user sees first, outside of any slow Suspense boundary, so it renders immediately as part of the initial HTML
const product = await getProductCore(); // Fast, no Suspense needed
<HeroImage src={product.imageUrl} />
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews /> {/* Not part of LCP, so can safely be deferred */}
</Suspense>
3. Skeleton not matching actual content’s size
Skeletons make pages feel faster, but if the skeleton’s dimensions don’t match the real content, you get a visible jump in layout when the skeleton is replaced by the actual content. For example if your skeleton is 300px tall and the content to be rendered is 400px, everything below it gets pushed down by 50px when the content gets rendered.
The skeleton didn't match the real component's size, so everything below it jumps the moment swap happens. ALT
The fix: Give your skeleton the same height, aspect ratio, or minimum height as the real content it's standing in for, as pixel-perfect as you can get, so nothing shifts when the swap happens.
Size the skeleton to match the real content, and the swap happens without anything else moving. ALT
6. Conclusion:
Traditional SSR forced us to either choose between blocking entire HTTP response until every request finishes or hand over rendering entirely to the client and deal with bloat JS, layout shift, and weak SEO
Streaming and Selective Hydration change how that whole process works. Instead of treating a page load as one big event, Next.js breaks it into pieces: the static skeleton lands in milliseconds, server data streams in as soon as it resolves, and React hydrates components in small chunks without locking up the UI.
To actually get those benefits, you have to build with these in mind:
● Avoid nested Suspense. Nesting async components inside each other forces the server to fetch data sequentially instead of in parallel.
● Keep LCP elements out of slow boundaries. If your hero banner or main title sits inside a slow Suspense chunk, your page will still feel slow to load.
● Size your skeletons accurately. If a placeholder is 300px tall and the incoming component is 400px the page will jump when it swaps in, affecting your CLS score.
When you get these patterns right, streaming gives you the best of both worlds: the reliability and SEO of SSR, paired with the instant, snappy feel of CSR.
Struggling to choose the right Headless CMS & Headless Commerce tech stack? We’ll help you pick the best solution for your business! Exclusive Offer: 20 Hours of Free Development & Consultation
Book a Meeting