React 18 introduces concurrent features that fundamentally change the way React applications are rendered. We will explore how these latest features impact and enhance application performance.
First, let's get a brief understanding of long tasks and the corresponding performance measurement basics.
Main Thread and Long Tasks#
When we run JavaScript in the browser, the JavaScript engine executes code in a single-threaded environment, commonly referred to as the main thread. In addition to executing JavaScript code, the main thread is also responsible for handling other tasks, including managing user interactions such as clicks and keyboard events, processing network events, timers, updating animations, and managing the browser's reflows and repaints.
When a task is being processed, all other tasks must wait. While the browser can smoothly execute small tasks to provide a seamless user experience, long-running tasks can cause issues as they may block the processing of other tasks.
Any task that runs longer than 50 milliseconds is considered a “long task”.
This 50-millisecond benchmark is based on the fact that devices must create a new frame every 16 milliseconds (60fps) to maintain a smooth visual experience. However, devices also need to perform other tasks, such as responding to user input and executing JavaScript code.
This 50-millisecond benchmark allows devices to allocate resources for rendering frames and executing other tasks simultaneously, providing an additional approximately 33.33 milliseconds to perform other tasks while maintaining a smooth visual experience. You can learn more about the 50-millisecond benchmark in this blog post.
To maintain optimal performance, it is important to minimize the number of long tasks. To measure website performance, there are two metrics that assess the impact of long tasks on application performance: TBT (Total Blocking Time) and INP (Interaction to Next Paint).
TBT (Total Blocking Time)#
Total Blocking Time (TBT) is an important metric that measures the time between First Contentful Paint (FCP) and Time to Interactive (TTI). TBT is the sum of the time spent executing tasks that exceed 50 milliseconds, which can significantly impact user experience.
INP (Interaction to Next Paint)#
Interaction to Next Paint (INP) is a new core web performance metric that measures the time from when a user first interacts with the page (e.g., clicking a button) to when that interaction is visible on the screen; that is, the next paint. This metric is especially important for pages with many user interactions, such as e-commerce sites or social media platforms. It measures by accumulating all INP measurements during the current visit and returning the worst score.
To understand how React 18 optimizes for these metrics to improve user experience, it is important to understand how traditional React works.
Traditional React Rendering Mechanism#
In React, visual updates are divided into two phases: the render phase and the commit phase. The render phase in React is a purely computational phase where React elements are compared with the existing DOM. This phase involves creating a new React tree, also known as the "virtual DOM," which is essentially a lightweight in-memory representation of the actual DOM.
During the render phase, React calculates the differences between the current DOM and the new React component tree and prepares the necessary updates.
Following the render phase is the commit phase. In this phase, React applies the updates calculated during the render phase to the actual DOM. This involves creating, updating, and deleting DOM nodes to map the new React component tree.
In traditional synchronous rendering, React gives all elements in the component tree the same priority. When the component tree is rendered, whether during the initial render or during a state update, React continues to render the entire tree structure, forming a single uninterruptible task, which is then committed to the DOM to visually update the components on the screen.
Synchronous rendering is an "all or nothing" operation that guarantees that the components that start rendering will complete the rendering process. Depending on the complexity of the components, the render phase may take some time to complete. During this time, the main thread is blocked, meaning users will encounter an unresponsive interface when trying to interact with the application until React completes the rendering and commits the results to the DOM.
This phenomenon can be seen in the following demonstration. We have a text input field and a large list of cities that is filtered based on the current value of the text input field. In synchronous rendering, React re-renders the CitiesList
component on every keystroke. Since the list contains thousands of cities, this is a fairly expensive computation, resulting in a noticeable visual feedback delay between keystrokes and seeing the input in the text field.
If using a high-end device like a Macbook, you may need to throttle CPU performance by 4 times to simulate a low-end device scenario. This setting can be found in the developer tools under Performance > ⚙️ > CPU.
When we look at the Performance tab, you will notice that there are long tasks occurring on every keystroke, which is not an ideal situation.
In this case, React developers often use third-party libraries (like Debounce
) to delay rendering, but there is no built-in solution.
React 18 introduces a new concurrent renderer that runs in the background. This renderer provides several methods that allow us to mark certain renders as non-urgent.
In this case, React yields the main thread every 5 milliseconds to see if there are more important tasks to handle, such as user input or rendering another React component state update, which are more important for the user experience in the current situation. By continuously yielding the main thread, React can make these renders non-blocking and prioritize more important tasks.
Additionally, this concurrent renderer can "concurrently" render multiple versions of the component tree in the background without immediately committing the results.
While synchronous rendering is an all-or-nothing computational process, this concurrent renderer allows React to pause and resume the rendering of one or more component trees for an optimized user experience.
By utilizing concurrent features, React can pause and resume component rendering based on external events such as user interactions. When a user begins interacting with ComponentTwo
, React pauses the current render, prioritizes rendering ComponentTwo
, and then resumes rendering ComponentOne
.
Transitions#
We can use the startTransition
function provided by the useTransition
hook to mark updates as non-urgent. This is a powerful new feature that allows us to mark certain state updates as "transitions," indicating that they may cause visual changes that could interfere with the user experience if rendered synchronously.
By wrapping state updates in startTransition
, we can inform React that we can delay or interrupt the rendering to prioritize more important tasks, keeping the current user interface interactive.
import { useTransition } from "react";
function Button() {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => {
urgentUpdate();
startTransition(() => {
nonUrgentUpdate()
})
}}
>...</button>
)
}
When a transition begins, the concurrent renderer prepares a new tree structure in the background. Once rendering is complete, it keeps the results in memory until the React scheduler can efficiently update the DOM to reflect the new state. This timing may occur when the browser is idle, with no higher-priority tasks (like user interactions) waiting.
Using transitions in the CitiesList
demonstration is ideal. Instead of directly updating the value passed to the searchQuery
parameter on every keystroke (thus triggering synchronous render calls each time), we can split the state into two values and wrap the state update for searchQuery
in startTransition
.
This tells React that the state update may cause visually disruptive changes for the user, so React should try to keep the current interactive UI responsive while preparing the new state in the background without immediately committing the update.
Now, when we type in the input field, user input remains smooth, with no visual delay between keystrokes. This is because the text
state is still synchronously updated, and the input field uses it as its value
.
In the background, React begins rendering the new tree structure on each keystroke. But instead of becoming an all-or-nothing synchronous task, React starts preparing a new version of the component tree in memory while the current UI (displaying the "old" state) remains responsive to further user input.
In the Performance tab, wrapping state updates in startTransition
significantly reduces the number of long tasks and total blocking time compared to the performance chart without transitions.
Transitions
represent a fundamental shift in React's rendering model, allowing React to render multiple versions of the UI simultaneously and manage priorities between different tasks. This can lead to a smoother and more responsive user experience, especially when dealing with high-frequency updates or CPU-intensive rendering tasks.
React Server Components (RSC)#
React Server Components (RSC)
is an experimental feature in React 18, but it is already ready for framework adoption. It is important to understand this before diving into Next.js.
Traditionally, React provides several primary ways to render applications. We can fully render everything on the client (Client-Side Rendering, CSR), or we can render the component tree as HTML on the server and send this static HTML along with a JavaScript bundle to the client for component hydration (Server-Side Rendering, SSR).
Both methods rely on the synchronous React renderer needing to rebuild the component tree on the client using the provided JavaScript bundle, even if this component tree is already available on the server.
RSC allows React to send the actual serialized component tree to the client. The React renderer on the client understands this format and uses it to efficiently reconstruct the React component tree without sending an HTML file or JavaScript bundle.
We can use this new rendering mode by combining the renderToPipeableStream
method from react-server-dom-webpack/server
with the createRoot
method from react-dom/client
.
// server/index.js
import App from '../src/App.js'
app.get('/rsc', async function(req, res) {
const {pipe} = renderToPipeableStream(React.createElement(App));
return pipe(res);
});
---
// src/index.js
import { createRoot } from 'react-dom/client';
import { createFromFetch } from 'react-server-dom-webpack/client';
export function Index() {
...
return createFromFetch(fetch('/rsc'));
}
const root = createRoot(document.getElementById('root'));
root.render(<Index />);
Click here to see the complete code and demonstration. In the next section, we will introduce a more detailed example.
By default, React does not hydrate RSC
. These components should not use any client interactions, such as accessing the window
object or using hooks like useState
or useEffect
.
To make a component and its imports interactive by adding them to the JavaScript bundle, you can use the “use client” directive at the top of the file. This tells the Bundler
to add this component and its imports to the client bundle and instructs React to hydrate on the client to add interactivity. These components are referred to as Client Components
.
When using Client Components
, optimizing bundle size is up to the developer. Developers can achieve this by:
- Ensuring that only the lowest-level nodes of interactive components define the
“use client”
directive. This may require decoupling some components. - Passing the component tree as props instead of direct imports. This allows React to render child components as RSC without adding them to the client bundle.
Suspense#
Another important new concurrent feature is Suspense
. While it was introduced in React 16 for code splitting with React.lazy
, the new capabilities introduced in React 18 extend Suspense
to data fetching.
With Suspense
, we can delay the rendering of a component until certain conditions are met, such as loading data from a remote source. Meanwhile, we can render a fallback component to indicate that this component is still loading.
By declaratively defining the loading state, we reduce the need for conditional rendering logic. Using Suspense
in conjunction with RSC
, we can directly access server-side data sources without needing separate API endpoints, such as databases or file systems.
async function BlogPosts() {
const posts = await db.posts.findAll();
return '...';
}
export default function Page() {
return (
<Suspense fallback={<Skeleton />}>
<BlogPosts />
</Suspense>
)
}
The true power of Suspense
comes from its deep integration with React's concurrent features. For example, when a component is suspended and still waiting for data to load, React does not sit idle waiting for the component to receive data. Instead, it pauses the rendering of the suspended component and shifts focus to other tasks.
During this time, we can tell React to render a fallback UI to indicate that this component is still loading. Once the awaited data is available, React can seamlessly resume the rendering of the previously suspended component, just as we saw earlier with transitions.
React can also rearrange the priorities of components based on user interactions. For instance, when a user interacts with a currently suspended component that is not being rendered, React will pause the ongoing rendering and prioritize the component the user is interacting with.
Once ready, React submits it to the DOM and resumes the previous rendering. This ensures that user interactions are prioritized and the UI remains responsive, keeping up with user input.
The combination of Suspense
with the streamable format of RSC
allows high-priority updates to be sent to the client as soon as they are ready, without waiting for low-priority rendering tasks to complete. This enables the client to start processing data earlier and gradually display content in a non-blocking manner, providing a smoother experience for users.
This interruptible rendering mechanism, combined with Suspense
's ability to handle asynchronous operations, offers a smoother and more user-centric experience for complex applications, especially those with significant data-fetching requirements.
Data Fetching#
In addition to rendering updates, React 18 also introduces a new API for efficiently fetching and memoizing data.
React 18 now features a cache function that remembers the results of wrapped function calls. If the same function is called again with the same parameters during the same render pass, it will use the memoized value without executing the function again.
import { cache } from 'react'
export const getUser = cache(async (id) => {
const user = await db.user.findUnique({ id })
return user;
})
getUser(1)
getUser(1) // Called within same render pass: returns memoized result.
In fetch calls, React 18 now includes a similar caching mechanism by default, without needing to use cache. This helps reduce the number of network requests during a single render pass, improving application performance and lowering API costs.
export const fetchPost = async (id) => {
const res = await fetch(`https://.../posts/${id}`);
const data = await res.json();
return { post: data.post }
}
fetchPost(1)
fetchPost(1) // Called within same render pass: returns memoized result.
These features are particularly useful when using React Server components, as they do not have access to the Context API. The automatic caching behavior of cache and fetch allows a single function to be exported from a global module and reused throughout the application.
async function fetchBlogPost(id) {
const res = await fetch(`/api/posts/${id}`);
return res.json();
}
async function BlogPostLayout() {
const post = await fetchBlogPost('123');
return '...'
}
async function BlogPostContent() {
const post = await fetchBlogPost('123'); // Returns memoized value
return '...'
}
export default function Page() {
return (
<BlogPostLayout>
<BlogPostContent />
</BlogPostLayout>
)
}
Conclusion#
In summary, the latest features of React 18 enhance performance in many ways.
- With Concurrent React, the rendering process can be paused and resumed later, or even aborted. This means that even when a large rendering task is in progress, the UI can immediately respond to user input.
- The Transitions API allows for smoother transitions during data fetching or screen switching without blocking user input.
- React Server Components enable developers to build components that work on both the server and client, combining the interactivity of client applications with the performance of traditional server rendering without needing hydration.
- The extended
Suspense
functionality improves loading performance by allowing parts of the application to render before others, which may take longer to fetch data.
Developers using the Next.js App Router can now start leveraging the features available in the framework mentioned in this article, such as caching and Server Components.