Skip to content

Commit 0b17ac3

Browse files
authored
Move unhandled rejection handling to shared path (#77997)
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.
1 parent 0748935 commit 0b17ac3

File tree

4 files changed

+99
-17
lines changed

4 files changed

+99
-17
lines changed

Diff for: packages/next/src/server/config-schema.ts

+1
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
390390
prerenderEarlyExit: z.boolean().optional(),
391391
proxyTimeout: z.number().gte(0).optional(),
392392
routerBFCache: z.boolean().optional(),
393+
removeUnhandledRejectionListeners: z.boolean().optional(),
393394
scrollRestoration: z.boolean().optional(),
394395
sri: z
395396
.object({

Diff for: packages/next/src/server/config-shared.ts

+11
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,16 @@ export interface ExperimentalConfig {
509509
*/
510510
routerBFCache?: boolean
511511

512+
/**
513+
* Uninstalls all "unhandledRejection" listeners from the global process so
514+
* that we can override the behavior, which in some runtimes is to exit the
515+
* process on an unhandled rejection.
516+
*
517+
* This is experimental until we've considered the impact in various
518+
* deployment environments.
519+
*/
520+
removeUnhandledRejectionListeners?: boolean
521+
512522
serverActions?: {
513523
/**
514524
* Allows adjusting body parser size limit for server actions.
@@ -1302,6 +1312,7 @@ export const defaultConfig: NextConfig = {
13021312
useEarlyImport: false,
13031313
viewTransition: false,
13041314
routerBFCache: false,
1315+
removeUnhandledRejectionListeners: false,
13051316
staleTimes: {
13061317
dynamic: 0,
13071318
static: 300,

Diff for: packages/next/src/server/lib/start-server.ts

-17
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import { CONFIG_FILES } from '../../shared/lib/constants'
3030
import { getStartServerInfo, logStartInfo } from './app-info-log'
3131
import { validateTurboNextConfig } from '../../lib/turbopack-warning'
3232
import { type Span, trace, flushAllTraces } from '../../trace'
33-
import { isPostpone } from './router-utils/is-postpone'
3433
import { isIPv6 } from './is-ipv6'
3534
import { AsyncCallbackSet } from './async-callback-set'
3635
import type { NextServer } from '../next'
@@ -331,29 +330,13 @@ export async function startServer(
331330
process.exit(0)
332331
})()
333332
}
334-
const exception = (err: Error) => {
335-
if (isPostpone(err)) {
336-
// React postpones that are unhandled might end up logged here but they're
337-
// not really errors. They're just part of rendering.
338-
return
339-
}
340333

341-
// This is the render worker, we keep the process alive
342-
console.error(err)
343-
}
344334
// Make sure commands gracefully respect termination signals (e.g. from Docker)
345335
// Allow the graceful termination to be manually configurable
346336
if (!process.env.NEXT_MANUAL_SIG_HANDLE) {
347337
process.on('SIGINT', cleanup)
348338
process.on('SIGTERM', cleanup)
349339
}
350-
process.on('rejectionHandled', () => {
351-
// It is ok to await a Promise late in Next.js as it allows for better
352-
// prefetching patterns to avoid waterfalls. We ignore loggining these.
353-
// We should've already errored in anyway unhandledRejection.
354-
})
355-
process.on('uncaughtException', exception)
356-
process.on('unhandledRejection', exception)
357340

358341
const initResult = await getRequestHandlers({
359342
dir,

Diff for: packages/next/src/server/next-server.ts

+87
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ import { AsyncCallbackSet } from './lib/async-callback-set'
112112
import { initializeCacheHandlers, setCacheHandler } from './use-cache/handlers'
113113
import type { UnwrapPromise } from '../lib/coalesced-function'
114114
import { populateStaticEnv } from '../lib/static-env'
115+
import { isPostpone } from './lib/router-utils/is-postpone'
115116

116117
export * from './base-server'
117118

@@ -159,6 +160,87 @@ function getMiddlewareMatcher(
159160
return matcher
160161
}
161162

163+
function installProcessErrorHandlers(
164+
shouldRemoveUnhandledRejectionListeners: boolean
165+
) {
166+
// The conventional wisdom of Node.js and other runtimes is to treat
167+
// unhandled errors as fatal and exit the process.
168+
//
169+
// But Next.js is not a generic JS runtime — it's a specialized runtime for
170+
// React Server Components.
171+
//
172+
// Many unhandled rejections are due to the late-awaiting pattern for
173+
// prefetching data. In Next.js it's OK to call an async function without
174+
// immediately awaiting it, to start the request as soon as possible
175+
// without blocking unncessarily on the result. These can end up
176+
// triggering an "unhandledRejection" if it later turns out that the
177+
// data is not needed to render the page. Example:
178+
//
179+
// const promise = fetchData()
180+
// const shouldShow = await checkCondition()
181+
// if (shouldShow) {
182+
// return <Component promise={promise} />
183+
// }
184+
//
185+
// In this example, `fetchData` is called immediately to start the request
186+
// as soon as possible, but if `shouldShow` is false, then it will be
187+
// discarded without unwrapping its result. If it errors, it will trigger
188+
// an "unhandledRejection" event.
189+
//
190+
// Ideally, we would suppress these rejections completely without warning,
191+
// because we don't consider them real errors. (TODO: Currently we do warn.)
192+
//
193+
// But regardless of whether we do or don't warn, we definitely shouldn't
194+
// crash the entire process.
195+
//
196+
// Even a "legit" unhandled error unrelated to prefetching shouldn't
197+
// prevent the rest of the page from rendering.
198+
//
199+
// So, we're going to intentionally override the default error handling
200+
// behavior of the outer JS runtime to be more forgiving
201+
202+
// Remove any existing "unhandledRejection" handlers. This is gated behind
203+
// an experimental flag until we've considered the impact in various
204+
// deployment environments. It's possible this may always need to
205+
// be configurable.
206+
if (shouldRemoveUnhandledRejectionListeners) {
207+
process.removeAllListeners('unhandledRejection')
208+
}
209+
210+
// Install a new handler to prevent the process from crashing.
211+
process.on('unhandledRejection', (reason: unknown) => {
212+
if (isPostpone(reason)) {
213+
// React postpones that are unhandled might end up logged here but they're
214+
// not really errors. They're just part of rendering.
215+
return
216+
}
217+
// Immediately log the error.
218+
// TODO: Ideally, if we knew that this error was triggered by application
219+
// code, we would suppress it entirely without logging. We can't reliably
220+
// detect all of these, but when dynamicIO is enabled, we could suppress
221+
// at least some of them by waiting to log the error until after all in-
222+
// progress renders have completed. Then, only log errors for which there
223+
// was not a corresponding "rejectionHandled" event.
224+
console.error(reason)
225+
})
226+
227+
process.on('rejectionHandled', () => {
228+
// TODO: See note in the unhandledRejection handler above. In the future,
229+
// we may use the "rejectionHandled" event to de-queue an error from
230+
// being logged.
231+
})
232+
233+
// Unhandled exceptions are errors triggered by non-async functions, so this
234+
// is unrelated to the late-awaiting pattern. However, for similar reasons,
235+
// we still shouldn't crash the process. Just log it.
236+
process.on('uncaughtException', (reason: unknown) => {
237+
if (isPostpone(reason)) {
238+
return
239+
}
240+
console.error(reason)
241+
})
242+
}
243+
162244
export default class NextNodeServer extends BaseServer<
163245
Options,
164246
NodeNextRequest,
@@ -287,6 +369,11 @@ export default class NextNodeServer extends BaseServer<
287369
if (this.renderOpts.isExperimentalCompile) {
288370
populateStaticEnv(this.nextConfig)
289371
}
372+
373+
const shouldRemoveUnhandledRejectionListeners = Boolean(
374+
options.conf.experimental?.removeUnhandledRejectionListeners
375+
)
376+
installProcessErrorHandlers(shouldRemoveUnhandledRejectionListeners)
290377
}
291378

292379
public async unstable_preloadEntries(): Promise<void> {

0 commit comments

Comments
 (0)