How to Fix: Using a client-side promise on initial render hangs server stream
Experiencing a server stream hang on initial render due to a client-side promise in Next.js? This common pitfall in the App Router environment, where module-level asynchronous code in a Client Component can inadvertently block server resources, can be frustrating. Learn why this happens and how to fix it by correctly isolating client-specific logic.
Table of Contents
Understanding the Root Cause
The issue described "Using a client-side promise on initial render hangs server stream" in a Next.js App Router application stems from a fundamental misunderstanding of how Client Components are rendered during the Server-Side Rendering (SSR) phase. Even though a component is marked with 'use client', its module-level JavaScript code still executes in the Node.js environment on the server during the initial render pass.
Consider the problematic code snippet from the linked repository:
'use client';
import { useEffect, useState } from 'react';
const promise = new Promise((resolve) => setTimeout(resolve, 5000));
export default function Home() {
const [data, setData] = useState(0);
useEffect(() => {
promise.then(() => {
setData(1);
});
}, []);
return (
<main>
<h1>{data}</h1>
<a href="/?a=1">hard navigation</a>
</main>
);
}
Here’s a breakdown of what happens on the server:
-
Module-Level Code Execution: When a request comes in for this page (e.g., via "hard navigation"), Next.js initiates an SSR pass. During this, all JavaScript at the module level within
app/page.jsis executed in the Node.js server environment. This includes the line:const promise = new Promise((resolve) => setTimeout(resolve, 5000)); -
Immediate Promise Executor Call: The
Promiseconstructor’s executor function(resolve) => setTimeout(resolve, 5000)is called immediately. This schedules asetTimeoutto resolve the promise after 5 seconds. -
Node.js Event Loop Activity: Crucially,
setTimeoutschedules a callback on the Node.js event loop. While the server does not explicitlyawaitthis promise, the mere act of scheduling an active timer keeps the Node.js process’s event loop from becoming idle. The server will proceed to render the initial HTML and stream it to the client, but the scheduledsetTimeouttask remains active in the background. -
Perceived Server Hang (especially in Development): In a development environment (
npm run dev), Next.js often maintains a persistent Node.js server process for features like Fast Refresh. If newsetTimeoutcalls are continually initiated at the module level for every hard navigation without being cleared, the Node.js event loop remains busy with these pending timers. This can lead to the server process appearing to "hang" or become unresponsive, delaying subsequent requests, increasing memory usage, or preventing the process from gracefully exiting, thus impacting the "server stream" and overall performance. -
useEffectis Client-Only: TheuseEffecthook, where this promise is consumed viapromise.then(), is designed to run only on the client after hydration. Therefore, the server does not wait for the promise to resolve before rendering the component’s initial state (which isdata = 0in this case).
In essence, you’re running client-side specific asynchronous logic on the server, which creates an unintended side effect that keeps the Node.js event loop active, causing the perceived hang.
Step-by-Step Solution
The solution involves ensuring that any client-specific asynchronous operations, especially those involving timers or browser APIs, are strictly confined to the client-side lifecycle, typically within a useEffect hook.
Step 1: Identify and Relocate the Client-Side Promise
The problematic promise is defined at the module level in your client component. It needs to be moved inside the useEffect hook to ensure it only executes on the client after the component has mounted and hydrated.
Step 2: Implement Cleanup for the Timer
When creating timers inside useEffect, it’s crucial to provide a cleanup function. This function runs when the component unmounts or before the effect re-runs, preventing memory leaks and ensuring that timers scheduled on the client don’t persist unnecessarily if the component is no longer active.
Step 3: Refactor the Code
Here’s how to refactor your app/page.js:
'use client';
import { useEffect, useState } from 'react';
export default function Home() {
const [data, setData] = useState(0);
useEffect(() => {
let timerId; // Declare timerId outside the promise to access it in cleanup
// Create the promise and schedule the timeout ONLY on the client
const clientSidePromise = new Promise((resolve) => {
timerId = setTimeout(() => {
console.log('Promise resolved on client after 5 seconds');
resolve();
}, 5000);
});
clientSidePromise.then(() => {
setData(1);
});
// Cleanup function: important to clear the timeout if component unmounts
return () => {
if (timerId) {
console.log('Cleaning up timeout...');
clearTimeout(timerId);
}
};
}, []); // Empty dependency array ensures this effect runs only once on mount
return (
<main>
<h1>{data}</h1>
<a href="/?a=1">hard navigation</a>
</main>
);
}
Explanation of Changes:
- The
promiseis now defined directly within theuseEffectcallback. This ensures that its executor function, and thus thesetTimeoutcall, only runs on the client-side after the component has been hydrated. - A
timerIdvariable is used to store the ID returned bysetTimeout. - A cleanup function is returned from
useEffectthat callsclearTimeout(timerId). This is vital for preventing memory leaks and ensuring that if the component unmounts before the 5 seconds are up, the pending timer is properly canceled.
With these changes, the server-side rendering process will complete swiftly without initiating any long-running timers, and the client-side logic will execute as intended without affecting server resources.
Common Edge Cases
-
External Libraries and Module Initialization: Be wary of any third-party libraries imported into a client component that perform asynchronous operations or set up global timers/listeners during their module initialization. If these libraries are not explicitly designed to be SSR-safe, they can cause similar issues. Look for options to delay their initialization until a
useEffecthook. -
Global Event Listeners or DOM Manipulations: Any code that directly interacts with the browser’s DOM or global objects (like
windowordocument) outside ofuseEffectwill likely cause errors during SSR, as these objects are not available on the server. While not directly a "hanging stream" issue, it highlights the need to confine client-specific logic. -
Misunderstanding Client vs. Server Boundaries: The Next.js App Router’s distinction between Server Components and Client Components, and the fact that Client Components are still rendered on the server, is a common source of confusion. Always assume module-level code in Client Components will run on the server, and plan asynchronous side effects accordingly.
-
Complex Asynchronous Chains: If your client-side logic involves multiple chained promises or intricate async/await patterns at the module level, the problem can be harder to debug. The solution remains the same: move all such logic into
useEffector a similar client-only lifecycle hook.
FAQ
1. Why does this only happen on "hard navigation" or initial render?
This issue primarily occurs on "hard navigation" (a full page reload or initial visit) because that’s when the Next.js server performs its initial Server-Side Rendering (SSR) pass. During SSR, the server evaluates the Client Component’s module-level code. Subsequent client-side navigations (e.g., using <Link>) bypass this full server-side render, instead leveraging client-side routing, so the problematic module-level code is not re-executed on the server.
2. Can Promise.resolve() or Promise.reject() at the module level also cause issues?
While Promise.resolve() or Promise.reject() directly at the module level (e.g., const instantPromise = Promise.resolve('data');) are unlikely to cause a "hang" because they resolve synchronously in the same tick of the event loop, it is still generally a bad practice for client-only logic. It unnecessarily runs code on the server that is not intended for the server and can lead to subtle inconsistencies or resource usage if not carefully managed. The best practice is to confine all client-specific promises to useEffect.
3. How can I load data on the server for a client component?
If your Client Component needs data that should be fetched on the server, you have a few options:
-
Pass as Props from a Server Component: The most common and recommended approach is to fetch data in a parent Server Component and pass it down as props to your Client Component.
// app/parent/page.js (Server Component) import ClientComponent from './client-component'; async function getServerData() { const res = await fetch('...'); return res.json(); } export default async function Page() { const data = await getServerData(); return <ClientComponent initialData={data} />; } -
Server Actions: For mutations or server-side functions triggered from the client, you can use Server Actions, which allow you to define server-side code within Client Components or call them directly from client-side event handlers.
-
Route Handlers: For API endpoints, create a
route.js(orroute.ts) file within yourappdirectory to define an API route, and then fetch from this route within your Client Component’suseEffect.
Avoid fetching data directly within a Client Component’s module scope if that data is critical for the initial server render.