Guides

Why does my Node error handler fail or crash the server in Next.js?

Four things catch out code that works in plain Node. A namespace import of process has no on method. Next.js keeps your unhandledRejection listeners in its own list, so listenerCount looks wrong, and exiting on that kills a server Next meant to keep up. An early return in instrumentation.ts still sends your Node imports to the Edge build. And Next bundles a package separately for instrumentation and for routes, so a module-level client isn't shared. We hit all four in Runheed's Node SDK, running it in real Next.js 16 apps, after unit tests had passed.

Trap 1: import * as process has no process.on

In ESM, a namespace import of a built-in module holds its named exports. process.on lives on the EventEmitter prototype, so it isn't one of them. In Node 22 this throws:

broken.mjsjavascript
import * as process from 'node:process';

process.on('uncaughtException', report);
// TypeError: process.on is not a function

It gets worse if setup sits inside a try/catch so that a monitoring tool never breaks the host app. Then nothing fails and nothing gets reported. The CommonJS build works, because require gives you the real object, so a test that only loads CommonJS stays green. Use the global process, or import process from 'node:process', and run your tests against the ESM build too.

Trap 2: Next.js keeps your rejection listeners in its own list

In Node, adding an unhandledRejection listener turns off the default crash, so a careful library copies Node's behavior: if it is the only listener, report the error and then exit. The usual check is process.listenerCount('unhandledRejection') === 1.

In Next.js 16.4.5, server/node-environment-extensions/unhandled-rejection.external.js installs one filtering listener, then patches process.on, addListener, once, prependListener and listeners, so later listeners go into an internal list. It leaves listenerCount alone. So listenerCount reports 1 no matter how many handlers exist, while process.listeners() returns Next's filter plus that list.

Next also doesn't treat these rejections as fatal, on purpose. The comment in its process-error-handlers.js says the conventional wisdom is to exit, but that many of these come from starting a fetch early and later not needing the result. So a library that exits on a count of 1 takes down a production server that was meant to stay up. Ours did this in a test app's production build, which is how we found it.

exit-like-node.tstypescript
function hasOtherHandlers(event: 'uncaughtException' | 'unhandledRejection') {
  // listeners(), not listenerCount(). NEXT_RUNTIME is set inside a Next.js server.
  return process.listeners(event as 'uncaughtException').length > 1 || Boolean(process.env.NEXT_RUNTIME);
}

Also read the --unhandled-rejections flag from process.execArgv and NODE_OPTIONS before exiting: under warn or none, Node itself would have kept running.

Trap 3: an early return still compiles your Node imports for Edge

instrumentation.ts is compiled for both the Node.js and Edge runtimes. This looks equivalent to wrapping the code in an if, but it isn't:

instrumentation.tstypescript
// Fails: the Edge build still sees the import
export async function register() {
  if (process.env.NEXT_RUNTIME !== 'nodejs') return;
  const { default: sdk } = await import('some-node-sdk');
  sdk.init();
}

// Works: the bundler drops the whole branch from the Edge build
export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { default: sdk } = await import('some-node-sdk');
    sdk.init();
  }
}

With the first form, a package that imports os failed the Edge compile with "Can't resolve 'os'", and every page returned 500, not just the routes that report errors. We checked the second form in next dev and in next build plus next start, with both webpack and Turbopack. Next's own docs show another safe shape: a separate file per runtime, loaded behind the check.

Trap 4: bundling gives you two copies of your singleton

Next bundles a package separately for instrumentation.ts and for your routes. A module-level let client set by init() in instrumentation is still undefined in the copy a route handler imports, so captureException in that route quietly does nothing. Keep the state on globalThis under a registered symbol, which every copy resolves to the same value:

client.tstypescript
const KEY = Symbol.for('my-sdk.client');
const store = globalThis as unknown as Record<symbol, Client | undefined>;

export function init(options: Options) {
  store[KEY] = new Client(options);
}

export function captureException(error: Error) {
  store[KEY]?.capture(error);
}

The same fix helps when two versions of a package end up in one node_modules tree.

How to catch these before your users do

  • Run the exact code from your setup docs in a fresh install of the framework, in development and in a production build, with each bundler it offers.
  • Throw from a route, a server component, a timer and a floating promise. Check that each one arrives and that the server still answers afterwards.
  • Run a control without your code when something looks wrong, so you know whose bug it is.
  • Treat any try/catch around setup as a place where failure is silent, and assert on the real effect, not on the absence of an exception.

Runheed's Node SDK ships these fixes, and its setup page for Next.js is at /for/nextjs.

Questions

Does adding an unhandledRejection listener stop Node from crashing?
Yes. By default an unhandled rejection ends the process with exit code 1. With a listener installed, the process keeps running, so a library that adds one changes the behavior of the host app unless it copies the default.
Why does process.listenerCount disagree with process.listeners in Next.js?
In Next.js 16.4.5, the unhandled rejection filter patches process.listeners to return its filter plus its internal list, but leaves listenerCount reporting the one real listener, which is the filter.
Can I turn the Next.js rejection filter off?
In 16.4.5 the source reads NEXT_UNHANDLED_REJECTION_FILTER, which accepts disabled, false or 0. It is an internal detail and can change between releases, so check your version.

Related