In modern web development, users expect immediate responses and smooth experiences. To meet these expectations, frontend frameworks continue to evolve, making performance optimization a central concern. As a leading React framework, Next.js provides powerful streaming and Suspense capabilities, which are essential for building high-performance applications with excellent user experiences. This tutorial takes you deep into how these technologies work and how they have evolved, then uses hands-on code examples to help you master the art of streaming rendering in Next.js applications.
1. Introduction: Why Do We Need Streaming and Suspense?
Traditional server-side rendering (SSR) generally follows an "all or nothing" model: the server must complete all data fetching and page rendering before sending the complete HTML document to the browser. If any data-fetching step takes a long time, users can do nothing but stare at a blank screen or loading indicator until the entire page is ready. This results in longer Time to First Byte (TTFB) and First Contentful Paint (FCP) times, negatively affecting perceived performance.
Streaming was created to solve precisely this problem.
1.1 Streaming Explained: From "Fill It All at Once" to "Drink as You Fill"
Let's revisit the cup-filling analogy and take it a step further:
- Traditional SSR (fill it all at once): You have to wait for the faucet to fill the entire cup (the server completes all data fetching and rendering) before handing the full cup to the thirsty user (sending the complete HTML). The user waits a long time.
- Streaming SSR (drink as you fill): You first hand the cup to the user, then turn on the faucet (the server sends the HTML skeleton and immediately available content). As the water flows, the user can begin drinking the water at the bottom of the cup (the browser starts rendering immediately available content, such as the page layout and static text). While the faucet is still running (the server is still fetching slow data), the user has already quenched their thirst. Eventually, the cup fills up (the server sends the remaining data and corresponding HTML fragments), and the user never has to endure a long initial wait.
In a web application, the "water" can represent HTML, inline JSON data, or even snippets of JavaScript that perform DOM operations. Streaming allows the server to send content to the browser progressively as it is generated. Once the browser receives these fragments, it can parse and render them immediately, allowing users to see parts of the page sooner and significantly improving interactivity and perceived performance.
1.2 HTTP Chunked Transfer Encoding (Transfer-Encoding: chunked)
One of the technical foundations of streaming is chunked transfer encoding, introduced in HTTP/1.1. When a server sends a response with the Transfer-Encoding: chunked header, it does not need to know the total size of the response body in advance. The server can divide the body into any number of chunks, each containing size information and the data itself, and send them one by one. A final chunk with a size of 0 indicates that the response has ended. This allows the server to generate content dynamically and send it immediately without buffering the entire response.
2. Core Concept: React Suspense — The Foundation of Streaming Rendering
React Suspense is a core feature introduced in React 18 and the key to implementing elegant streaming rendering and handling asynchronous operations. Suspense lets developers declaratively manage the loading states of components and their data.
2.1 The Design Philosophy of Suspense
The central idea behind Suspense is: Let a component "pause" rendering until the data it depends on is ready, while displaying a designated fallback UI to fill the waiting time. This decouples the management of data-fetching wait states from component logic and leaves it to the React framework.
2.2 Basic Syntax and fallback
In this example:
- The
<Suspense>component wraps components that may require asynchronous loading of code or data (<Comments />,<UserProfile />). - The
fallbackprop accepts a React element, such as a loading indicator or skeleton screen, which is displayed while the wrapped component is suspended.
2.3 How Suspense "Pauses" Rendering
When React renders a component wrapped in <Suspense> and that component or one of its descendants triggers an asynchronous operation—for example, loading component code through React.lazy, or reading a Promise with the experimental use hook in the future—the component "throws" a Promise.
The nearest parent <Suspense> component catches this Promise. React then:
- Pauses rendering of every component within that Suspense boundary.
- Displays the Suspense component's
fallbackUI. - Listens for the Promise to resolve. Once it does, React retries rendering the previously suspended component tree. If rendering succeeds, React replaces the
fallbackUI with the actual content.
2.4 A Detailed Look at Its Representation in HTML
In Next.js and other frameworks that support streaming SSR, a Suspense boundary is rendered in the initial HTML as a special placeholder structure:
This special structure, <template id="boundary-id"></template>Fallback UI, contains:
- Start marker ``: Marks the beginning of the Suspense boundary.
$$may indicate that this is a resumable boundary. - Boundary marker
<template id="boundary-id">: A<template>element whoseidserves as the unique identifier for this Suspense boundary. The contents of a<template>element are not rendered by default, making it suitable as a placeholder. - Loading-state UI (
Fallback UI): Thefallbackcontent displayed initially. - End marker ``: Marks the end of the Suspense boundary.
/$may indicate the end of the boundary.
Once the server has the actual content, it streams an HTML fragment containing it—usually wrapped in a hidden <div> with a unique ID such as S:0—along with a <script> tag that calls a specific JavaScript function, such as $RC. That function replaces the fallback UI with the actual content.
2.5 Suspense Boundaries and Nesting
You can nest Suspense components to create more granular control over loading states. An inner Suspense boundary handles its own loading state first.
If both NewsFeed and ChatWidget take time to load, users first see PageSkeleton. Once NavBar, Sidebar, and Footer have rendered, they see those components along with FeedLoading and ChatLoading in the respective positions of NewsFeed and ChatWidget.
3. The Evolution of Next.js Streaming Rendering
Next.js's streaming rendering implementation did not appear overnight; it evolved over time. Earlier implementations may have relied more heavily on low-level React APIs, whereas modern implementations are more integrated and automated.
3.1 From $RC to the Abstraction of __next_f
The $RC function used in this tutorial can be viewed as a basic, simplified representation of the streaming replacement mechanism. It directly manipulates the DOM, locates the boundary and content by ID, and then replaces one with the other. This approach is straightforward and makes the core idea behind streaming replacement easy to understand.
However, the needs of modern web applications extend far beyond simple HTML replacement. We need to pass component props and state, handle event listeners, support client-side route navigation, and more. To support these complex scenarios, Next.js introduced a more advanced mechanism. The __next_f queue mentioned in this tutorial is part of that mechanism. Note that __next_f is an internal implementation detail that may change between versions; here, it serves as shorthand for the modern mechanism.
The __next_f queue pattern no longer merely sends prerendered HTML and a simple replacement function call. Instead, it sends serialized rendering instructions and component data. The client's Next.js runtime processes those instructions, which may include:
- Inserting the received HTML fragment into the DOM.
- Locating the corresponding Suspense boundary.
- Passing JSON data, including props and state, to components awaiting hydration.
- Performing hydration so the components become interactive, with event listeners and other functionality attached.
This approach is more flexible and powerful. It supports the full lifecycle and interactivity of React components rather than merely replacing static content.
4. Hands-On Example 1: Basic Streaming (Simulating the $RC Function)
This example simulates the most basic concept of streamed HTML replacement to help you understand the core flow.
4.1 Scenario
Imagine a blog page whose main content can be displayed immediately but whose comments section takes a long time to load from a database. We want to display the page skeleton and the comments section's loading state first, then stream in the comment data once it is ready and replace the loading state.
4.2 Server-Side Implementation (Node.js + Express Example)
4.3 Detailed Breakdown of the Client-Side $RC Function
This simplified $RC(boundaryId, contentId) function performs the following operations:
- Find the elements: Use
document.getElementByIdto locate the boundary<template>(boundaryTemplate) and the hidden<div>containing the actual content (contentElement). - Locate the container: Find the
<template>element's parent node, which is the container holding the Suspense boundary (boundaryContainer). - Remove the fallback UI: Starting with the
<template>element's next sibling, traverse and remove nodes until reaching and removing the end comment ``. - Remove the boundary markers: Remove the
<template>element itself and the start comment `` preceding it. - Insert the new content: Move all child nodes from
contentElement, which contain the actual comment HTML, intoboundaryContainer. - Clean up: Remove the now-empty
contentElementcontainer.
4.4 Limitations
The main limitations of this basic implementation are:
- Static HTML only: It only replaces HTML content and does not handle JavaScript event listeners or component state, meaning there is no hydration. If the comments section needs to be interactive—for example, to support likes or replies—this approach is insufficient.
- Manual ID management: Developers must manually coordinate the server-generated boundary ID (
B:comments), content ID (S:comments), and the client's$RCcall. - Fragile DOM manipulation: Direct DOM manipulation can fail when it encounters an HTML structure different from what was expected.
- No error handling: It does not handle data-fetching failures or rendering errors.
5. Hands-On Example 2: Modern Streaming (Simulating the __next_f Queue)
This example simulates a more modern approach in which the server sends structured instructions and data, while the client runtime interprets those instructions and performs rendering and hydration.
5.1 Scenario
Imagine a user profile page containing basic information that loads quickly and a dynamically loaded user activity feed that takes longer. We want the feed to support interactions, such as clicking a button to load more items.
5.2 Server-Side Implementation (Node.js + Express Example)
5.3 Client-Side Processing Logic (modern-streaming-client.js)
- Initialize the
__next_fqueue: Initializeself.__next_f = []as early as possible in the HTMLhead, ensuring that subsequentpushoperations have a target array. - Component registry (
ComponentRegistry): Define an object that maps component names to their actual rendering or hydration logic. Here, theActivityFeed.hydratefunction attaches event listeners, such as the one for the "Load More" button, to the server-rendered HTML. $RCfunction: A function is still needed to perform the HTML replacement, but it is now part of the client runtime. This version of$RCacceptsboundaryIdandcontentHtml, rather thancontentId, and injects the HTML string directly.processNextFQueuefunction:- Loops through the instructions in the
self.__next_fqueue. - Parses the payload JSON in each instruction.
- Calls
$RCto insertpayload.htmlContentat the position corresponding topayload.boundaryId. - Locates the DOM element that was just inserted.
- If
payload.componentNameexists and is defined inComponentRegistry, calls itshydratemethod with the DOM element,payload.props, andpayload.datato attach the interactive behavior.
- Loops through the instructions in the
- Initialization and monitoring (
initializeStreaming):- Runs after
DOMContentLoaded. - First processes any instructions already in the queue, since the server may have pushed them before the script finished loading.
- Crucially, overrides the
self.__next_f.pushmethod. When the server later callsself.__next_f.push([...])through a<script>tag, the overridden method first calls the originalpushto add the instruction to the queue and then asynchronously triggers queue processing throughsetTimeout(processNextFQueue, 0). This ensures that new instructions are processed even if they arrive while a script is executing, while also avoiding blocking the main thread.
- Runs after
5.4 Advantages
The advantages of this modern approach are clear:
- Separation of data and presentation: The instructions contain structured data (
props,data), allowing the client to perform more sophisticated rendering and state management. - Full interactivity (hydration): Through the
hydratefunction, static server-rendered HTML can be "activated" with the complete functionality and event handling of React components. - Stronger abstraction: Developers work more with components and data rather than directly manipulating low-level DOM IDs and replacement logic. The framework handles most of the complexity.
- Error handling and recovery: Although the example does not demonstrate this in detail, the structure makes it easier to integrate error boundaries and more sophisticated recovery strategies.
6. Comparative Analysis of $RC and __next_f
| Feature | Original $RC Implementation (Simulated) | Modern __next_f Queue Implementation (Simulated) |
|---|---|---|
| Complexity | Simple and direct; easy to understand the basic concepts | More complex; involves serialization, a client runtime, and hydration |
| Data transfer | Difficult; generally transfers only prerendered HTML | Powerful; can transfer structured props and asynchronous data |
| Interactivity | None (static HTML replacement only) | Fully supported (through hydration, event listeners, and state) |
| Component model | Unaware of the React component model | Aware; can integrate with the React component lifecycle |
| Level of abstraction | Low; directly manipulates DOM IDs | High; developers work with components and instructions |
| Robustness/error handling | Basic and prone to errors | More robust; the framework can provide error-handling mechanisms |
| Use cases | Understanding concepts and injecting very simple static content | Modern web applications that require interactivity and complex state management |
The core difference: $RC focuses on the physical replacement of HTML fragments, whereas __next_f focuses on transmitting and executing rendering instructions and data. The end result is hydration, which turns streamed content into fully functional, interactive components.
7. Key Aspects of Streaming Rendering
7.1 Error Handling
Error handling in streaming requires particular care:
- Server-side errors: If the server encounters an error while fetching data or rendering a chunk during streaming, it can:
- Terminate the stream, resulting in an incomplete page.
- Send a special error instruction to the client so that the client's Suspense boundary can display an error state, provided the client runtime supports it. React's
ErrorBoundarycomponent can catch rendering errors on the client. - Skip the failed chunk and continue sending the others, potentially leaving some content missing.
- Client-side errors: If an error occurs during hydration, React's
ErrorBoundarycan catch it and display fallback UI, preventing the entire application from crashing.
7.2 Streaming Rendering and SEO
Search engine crawlers are steadily improving at indexing streamed content, but several considerations remain:
- Initial HTML matters: Ensure that the initially sent HTML, including the non-streamed portion and the
fallbackUI, contains essential SEO information such as the title, meta description, and main content skeleton. - Crawler wait time: Crawlers may not wait as long for all streamed chunks to load as real users do. Important content should appear early in the stream or be included in the initial HTML.
templatetags: Crawlers generally do not index the contents of<template>elements, which makes them suitable for hiding boundary IDs behind fallback UI.- Server Components (RSC): RSC can generate complete HTML, including content that previously required asynchronous loading. This is highly favorable for SEO because the content is already fully available on the server.
7.3 Caching Strategies
Streaming and caching can be combined, but care is required:
- Full-page caching: If the streamed response for an entire page can be cached—for example, when the content is identical for every user—a CDN can cache the complete chunked response.
- Fragment caching: More advanced strategies may cache the data or rendering results needed to generate individual streamed chunks, then dynamically assemble the streamed response at request time. This usually requires more sophisticated edge computing or server-side logic.
- Client-side caching: Data fetched by the client can be cached with React Query, SWR, or a Service Worker to avoid repeated requests.
7.4 Integration with the Next.js App Router (React Server Components)
The Next.js App Router, the /app directory, treats streaming and Suspense as first-class citizens.
- Streaming by default: Pages built with the App Router use streaming rendering by default.
loading.jsfiles: Creating aloading.jsfile in a route segment, or folder, automatically creates a Suspense boundary around that segment'spage.jsorlayout.js. While the segment's components, especially Server Components, are fetching data, the contents ofloading.jsare displayed as thefallback.jsx // app/dashboard/loading.js export default function Loading() { // 你可以添加任何 UI 组件,比如骨架屏 return <div>正在加载仪表盘数据...</div>; }- Server Components (RSC): RSCs execute on the server. When an RSC performs data fetching with
async/await, React automatically wraps it in a Suspense boundary—usually provided byloading.jsor a manually added<Suspense>—and streams the result to the client. This integrates data fetching and rendering seamlessly without requiring manual management of loading states.
The App Router and Server Components greatly simplify the implementation of streaming rendering by abstracting away low-level mechanisms such as management of the __next_f queue.
8. Performance Optimization Strategies
Even when using streaming, there is still room for optimization:
Prioritization:
- Critical content first: Ensure that above-the-fold content and the data and components required by core functionality appear early in the stream.
- Interaction first: Elements that require user interaction, such as buttons and forms, should be hydrated as early as possible.
- Use nested Suspense boundaries: Create separate Suspense boundaries for content with different levels of importance so that critical sections can finish loading first.
Progressive enhancement/hydration:
- Hydrate critical components first: On complex pages, prioritize hydrating components that are in the viewport or that users are likely to interact with first.
- Delay hydration of noncritical components: Components below the page or associated with secondary features can postpone hydration until they enter the viewport or the user attempts to interact with them.
Code splitting:
- Use
React.lazy()and dynamicimport()to load component code on demand and reduce the initial JavaScript payload. Streaming can send the HTML skeleton first and then trigger the download and rendering of a lazy-loaded component when needed.
- Use
Preloading/preconnecting:
- For critical resources used later in the stream, such as JS, CSS, fonts, and API endpoints, use
<link rel="preload">or<link rel="preconnect">to tell the browser to begin fetching them as early as possible.
CodeBlock Loading...- For critical resources used later in the stream, such as JS, CSS, fonts, and API endpoints, use
Avoiding request waterfalls:
- Parallelize data fetching whenever possible. If the data for a streamed chunk requires multiple requests, try initiating those requests in parallel on the server rather than making them sequentially after client-side hydration. Server Components have a natural advantage in this area.
Measurement and monitoring:
- Use browser developer tools—the Network panel to inspect streamed responses and the Performance panel to analyze rendering and hydration—as well as performance monitoring tools such as Vercel Analytics, New Relic, and Datadog to identify bottlenecks, measure metrics such as FCP, LCP, and TTI, and continuously optimize the streaming strategy.
9. React Server Components (RSC) and Partial/Selective Hydration
Streaming and Suspense are cornerstones of more advanced architectures.
9.1 React Server Components (RSC) in Depth
- Definition: RSC is a new type of React component that executes and renders only on the server. It can directly access server-side resources such as databases, file systems, and internal APIs, and its JavaScript code is never sent to the client.
- Advantages:
- Zero bundle footprint: For components that are purely presentational or need access to backend resources, there is no need to send a large amount of JavaScript to the client.
- Direct data access: Simplifies data-fetching logic without requiring API endpoints.
- Automatic code splitting: When used with Client Components (
"use client"), RSCs naturally create code-splitting boundaries. - A natural fit with streaming: RSC data fetching through
async/awaitintegrates naturally with Suspense and streaming. The server can stream the rendered result of an RSC, whether HTML or special instructions, to the client as soon as the data is ready.
- Example: See the
DashboardPageexample in Section 7.4.
9.2 Partial Hydration / Selective Hydration
- Goal: Solve a problem with traditional hydration: the entire application, or a large region of it, must be hydrated at once, even when some components are noninteractive or not yet needed, consuming resources unnecessarily.
- Selective hydration (a React 18 feature): React 18 can perform selective hydration with Suspense.
- The hydration of content wrapped in
<Suspense>can be interrupted and resumed. - React prioritizes hydrating the area with which the user is interacting. For example, if a page is still streaming, the user clicks a button within a
<Suspense>boundary that has already loaded and appeared, React prioritizes hydrating that boundary and its content even if other boundaries are still loading data. - Hydration is non-blocking, allowing the browser to respond to user input while hydration is in progress.
- The hydration of content wrapped in
- Partial hydration (a further concept, often associated with Islands Architecture):
- The core idea is that most of the page consists of static HTML, while only a small number of interactive "islands" need to be hydrated.
- Frameworks such as Astro are typical examples. Next.js App Router achieves a similar effect through a combination of RSC, which is static by default, and Client Components (
"use client"). Only components marked with"use client"and their descendants are sent to the client and hydrated.
9.3 Technical Synergy
RSC, streaming, Suspense, and selective hydration work together to deliver exceptional performance and a superior development experience:
- RSC fetches data and renders on the server, producing HTML or instructions.
- Streaming sends those results to the client progressively.
- Suspense displays the
fallbackwhile data is unavailable and coordinates the display of content when it arrives. - Selective hydration ensures that client-side JavaScript is efficiently attached only to the necessary DOM sections, prioritizes user interactions, and keeps the page responsive.
10. Hands-On Project and Learning Resources
- Use the demo project
- Visit the demo project
- Open the browser's developer tools, usually with F12.
- Switch to the Network panel, select the Doc or HTML filter, and refresh the page. Observe the
/blog-basic-streamingor/profile-modern-streamingrequest. You will see that the request takes a long time to complete and that the content in the Response tab arrives in chunks. Pay attention to theTransfer-Encoding: chunkedheader. - Switch to the Elements panel and observe how the DOM changes as streamed chunks arrive, particularly how the
fallbackUI at Suspense boundaries is replaced with actual content. - Open the Console panel to view the logs we added and understand how
$RCorprocessNextFQueueexecutes. - Interact with the "Load More" button in the modern streaming example and observe the event handling after client-side hydration.
- Recommended learning path:
- Understand HTTP
Transfer-Encoding: chunked. - Learn the basic use of React
SuspenseandReact.lazy. - Study
getServerSidePropsfor traditional SSR andgetStaticPropsfor SSG in the Next.js Pages Router. - Focus on the Next.js App Router:
- Understand the roles of
layout.js,page.js,loading.js, anderror.js. - Master the differences and use of Server Components (RSC) and Client Components (
"use client"). - Practice fetching data with
async/awaitin an RSC and observe the effect ofloading.js. - Try adding
<Suspense>boundaries manually for finer-grained control.
- Understand the roles of
- Understand HTTP
- Official documentation and other resources:
- Next.js Documentation — Build Your Application — Routing (App Router) (covers RSC, Streaming, and Suspense)
- Next.js Documentation — Build Your Application — Data Fetching
- React Documentation — Suspense
- React Documentation —
useHook (Experimental) - React Documentation — Server Components
- MDN — Transfer-Encoding
11. Conclusion
Next.js streaming and Suspense, particularly when combined with the App Router and React Server Components, represent the cutting edge of performance optimization for modern web applications. By dividing a page into independently loadable chunks and using Suspense to handle loading states elegantly, we can:
- Significantly reduce TTFB and FCP: Users see meaningful content sooner.
- Improve perceived performance: Even when total loading time remains unchanged, revealing content progressively feels faster than displaying a blank screen for a long time.
- Improve user experience: Loading states such as skeleton screens provide better context than a blank screen or a simple spinning icon.
- Simplify asynchronous handling: Suspense separates loading-state management from component logic.
- Optimize resource usage: RSC reduces the amount of JavaScript sent to the client, while selective hydration improves client-side efficiency.
Understanding the evolution from basic $RC-style HTML replacement to the modern __next_f instruction queue—and its highly abstracted form in the Next.js App Router—helps us appreciate the complex work frameworks perform under the hood. Mastering these technologies will enable you to build Next.js applications that are not only powerful, but also fast, responsive, and exceptionally pleasant to use.