Repository: vercel/next.js
Pull request: Move unhandled rejection handling to shared path

The conventional wisdom of Node.js and other runtimes is to treat unhandled errors as fatal and exit the process.

But Next.js is not a generic JS runtime — it's a specialized runtime for React Server Components.

Many unhandled rejections are due to the late-awaiting pattern for prefetching data. In Next.js it's OK to call an async function without immediately awaiting it, to start the request as soon as possible without blocking unncessarily on the result. These can end up triggering an "unhandledRejection" if it later turns out that the data is not needed to render the page. Example:

```js
const promise = fetchData()
const shouldShow = await checkCondition()
if (shouldShow) {
  return <Component promise={promise} />
}
```

In this example, `fetchData` is called immediately to start the request as soon as possible, but if `shouldShow` is false, then it will be discarded without unwrapping its result. If it errors, it will trigger an "unhandledRejection" event.

Ideally, we would suppress these rejections completely without warning, because we don't consider them real errors. But regardless of whether we do or don't warn, we definitely shouldn't crash the entire process.

Even a "legit" unhandled error unrelated to prefetching shouldn't prevent the rest of the page from rendering.

So, we intentionally override the default error handling behavior of the outer JS runtime to be more forgiving.

---

This was already the behavior of Next.js for self-hosted deployments — i.e. `next start` — but the rejection listeners were being installed in a code path that does not run for other deployment targets, like Vercel.

So what this PR does is move the rejection handling code to a path that's common to all deployement targets.

One possibly controversial aspect that is new to this PR is that it removes all existing "unhandledRejection" handlers, before Next.js installs its own. This is to override any handlers that exit the process. Generally we think this is fine because it's unlikely that Next.js will be deployed in such a way that it's sharing a process with non-Next.js applications; however, if we get feedback to the contrary, we'll figure out a strategy to deal with it.

Changed files:
filename: packages/next/src/server/config-schema.ts
patch: @@ -390,6 +390,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
         prerenderEarlyExit: z.boolean().optional(),
         proxyTimeout: z.number().gte(0).optional(),
         routerBFCache: z.boolean().optional(),
+        removeUnhandledRejectionListeners: z.boolean().optional(),
         scrollRestoration: z.boolean().optional(),
         sri: z
           .object({

filename: packages/next/src/server/config-shared.ts
patch: @@ -509,6 +509,16 @@ export interface ExperimentalConfig {
    */
   routerBFCache?: boolean
 
+  /**
+   * Uninstalls all "unhandledRejection" listeners from the global process so
+   * that we can override the behavior, which in some runtimes is to exit the
+   * process on an unhandled rejection.
+   *
+   * This is experimental until we've considered the impact in various
+   * deployment environments.
+   */
+  removeUnhandledRejectionListeners?: boolean
+
   serverActions?: {
     /**
      * Allows adjusting body parser size limit for server actions.
@@ -1302,6 +1312,7 @@ export const defaultConfig: NextConfig = {
     useEarlyImport: false,
     viewTransition: false,
     routerBFCache: false,
+    removeUnhandledRejectionListeners: false,
     staleTimes: {
       dynamic: 0,
       static: 300,

filename: packages/next/src/server/lib/start-server.ts
patch: @@ -30,7 +30,6 @@ import { CONFIG_FILES } from '../../shared/lib/constants'
 import { getStartServerInfo, logStartInfo } from './app-info-log'
 import { validateTurboNextConfig } from '../../lib/turbopack-warning'
 import { type Span, trace, flushAllTraces } from '../../trace'
-import { isPostpone } from './router-utils/is-postpone'
 import { isIPv6 } from './is-ipv6'
 import { AsyncCallbackSet } from './async-callback-set'
 import type { NextServer } from '../next'
@@ -331,29 +330,13 @@ export async function startServer(
             process.exit(0)
           })()
         }
-        const exception = (err: Error) => {
-          if (isPostpone(err)) {
-            // React postpones that are unhandled might end up logged here but they're
-            // not really errors. They're just part of rendering.
-            return
-          }
 
-          // This is the render worker, we keep the process alive
-          console.error(err)
-        }
         // Make sure commands gracefully respect termination signals (e.g. from Docker)
         // Allow the graceful termination to be manually configurable
         if (!process.env.NEXT_MANUAL_SIG_HANDLE) {
           process.on('SIGINT', cleanup)
           process.on('SIGTERM', cleanup)
         }
-        process.on('rejectionHandled', () => {
-          // It is ok to await a Promise late in Next.js as it allows for better
-          // prefetching patterns to avoid waterfalls. We ignore loggining these.
-          // We should've already errored in anyway unhandledRejection.
-        })
-        process.on('uncaughtException', exception)
-        process.on('unhandledRejection', exception)
 
         const initResult = await getRequestHandlers({
           dir,

filename: packages/next/src/server/next-server.ts
patch: @@ -112,6 +112,7 @@ import { AsyncCallbackSet } from './lib/async-callback-set'
 import { initializeCacheHandlers, setCacheHandler } from './use-cache/handlers'
 import type { UnwrapPromise } from '../lib/coalesced-function'
 import { populateStaticEnv } from '../lib/static-env'
+import { isPostpone } from './lib/router-utils/is-postpone'
 
 export * from './base-server'
 
@@ -159,6 +160,87 @@ function getMiddlewareMatcher(
   return matcher
 }
 
+function installProcessErrorHandlers(
+  shouldRemoveUnhandledRejectionListeners: boolean
+) {
+  // The conventional wisdom of Node.js and other runtimes is to treat
+  // unhandled errors as fatal and exit the process.
+  //
+  // But Next.js is not a generic JS runtime — it's a specialized runtime for
+  // React Server Components.
+  //
+  // Many unhandled rejections are due to the late-awaiting pattern for
+  // prefetching data. In Next.js it's OK to call an async function without
+  // immediately awaiting it, to start the request as soon as possible
+  // without blocking unncessarily on the result. These can end up
+  // triggering an "unhandledRejection" if it later turns out that the
+  // data is not needed to render the page. Example:
+  //
+  //     const promise = fetchData()
+  //     const shouldShow = await checkCondition()
+  //     if (shouldShow) {
+  //       return <Component promise={promise} />
+  //     }
+  //
+  // In this example, `fetchData` is called immediately to start the request
+  // as soon as possible, but if `shouldShow` is false, then it will be
+  // discarded without unwrapping its result. If it errors, it will trigger
+  // an "unhandledRejection" event.
+  //
+  // Ideally, we would suppress these rejections completely without warning,
+  // because we don't consider them real errors. (TODO: Currently we do warn.)
+  //
+  // But regardless of whether we do or don't warn, we definitely shouldn't
+  // crash the entire process.
+  //
+  // Even a "legit" unhandled error unrelated to prefetching shouldn't
+  // prevent the rest of the page from rendering.
+  //
+  // So, we're going to intentionally override the default error handling
+  // behavior of the outer JS runtime to be more forgiving
+
+  // Remove any existing "unhandledRejection" handlers. This is gated behind
+  // an experimental flag until we've considered the impact in various
+  // deployment environments. It's possible this may always need to
+  // be configurable.
+  if (shouldRemoveUnhandledRejectionListeners) {
+    process.removeAllListeners('unhandledRejection')
+  }
+
+  // Install a new handler to prevent the process from crashing.
+  process.on('unhandledRejection', (reason: unknown) => {
+    if (isPostpone(reason)) {
+      // React postpones that are unhandled might end up logged here but they're
+      // not really errors. They're just part of rendering.
+      return
+    }
+    // Immediately log the error.
+    // TODO: Ideally, if we knew that this error was triggered by application
+    // code, we would suppress it entirely without logging. We can't reliably
+    // detect all of these, but when dynamicIO is enabled, we could suppress
+    // at least some of them by waiting to log the error until after all in-
+    // progress renders have completed. Then, only log errors for which there
+    // was not a corresponding "rejectionHandled" event.
+    console.error(reason)
+  })
+
+  process.on('rejectionHandled', () => {
+    // TODO: See note in the unhandledRejection handler above. In the future,
+    // we may use the "rejectionHandled" event to de-queue an error from
+    // being logged.
+  })
+
+  // Unhandled exceptions are errors triggered by non-async functions, so this
+  // is unrelated to the late-awaiting pattern. However, for similar reasons,
+  // we still shouldn't crash the process. Just log it.
+  process.on('uncaughtException', (reason: unknown) => {
+    if (isPostpone(reason)) {
+      return
+    }
+    console.error(reason)
+  })
+}
+
 export default class NextNodeServer extends BaseServer<
   Options,
   NodeNextRequest,
@@ -287,6 +369,11 @@ export default class NextNodeServer extends BaseServer<
     if (this.renderOpts.isExperimentalCompile) {
       populateStaticEnv(this.nextConfig)
     }
+
+    const shouldRemoveUnhandledRejectionListeners = Boolean(
+      options.conf.experimental?.removeUnhandledRejectionListeners
+    )
+    installProcessErrorHandlers(shouldRemoveUnhandledRejectionListeners)
   }
 
   public async unstable_preloadEntries(): Promise<void> {