Skip to content
Latchkey

Node.js "--unhandled-rejections=strict" Changed CI Exit Behavior

Node’s --unhandled-rejections flag controls what happens when a promise rejects unhandled. A pipeline that set warn (or ran old Node) tolerated rejections; switching to strict/throw makes them fatal.

What this error means

After a Node upgrade or a changed NODE_OPTIONS, a job that previously only logged a warning now exits non-zero on the same unhandled rejection. Nothing in your code changed - only the rejection mode did.

Node output
# old behaviour (warn): job stayed green
(node:1) UnhandledPromiseRejectionWarning: TypeError: ...

# new behaviour (strict/throw): job fails
node:internal/process/promises:288  ... code: 'ERR_UNHANDLED_REJECTION'

Diagnose it: the shell in CI is not your shell

Package scripts run under a different shell, a different PATH, and a non-interactive environment on a runner. Most scripts that fail only in CI are relying on something the login shell gave them locally: a tool on PATH, an environment variable from a dotfile, or a TTY.

Terminal
# what the script can actually see
npm run env | grep -E "^(PATH|NODE_ENV|CI)=" 

# is the binary on PATH for the script, not just for you?
npm exec -- which <tool> || echo "not resolvable from npm scripts"

# run the exact script with tracing
sh -x -c "$(node -p "require('./package.json').scripts.build")"

Common causes

The default mode changed across Node versions

Older Node warned on unhandled rejections; Node 15+ defaults to throw. A runner image bump to a newer Node flips the same code from warning to fatal.

NODE_OPTIONS sets a stricter mode

A pipeline that adds --unhandled-rejections=strict raises severity so even rejections inside the first tick of loading abort the process.

How to fix it

Fix the rejection rather than the mode

The correct response to a newly-fatal rejection is to handle it. Lowering the mode just hides a real bug.

  1. Find the unhandled rejection in the stack or the prior warning.
  2. Await it in try/catch or attach .catch().
  3. Keep throw/strict so future rejections stay visible.

Set the mode deliberately if you must

If you genuinely need to soften behaviour during a migration, set the mode explicitly so it is documented - but treat it as temporary.

Terminal / workflow
# strict (recommended long-term)
node --unhandled-rejections=strict app.mjs
# or via env
NODE_OPTIONS=--unhandled-rejections=strict

Make failures fail the job

A multi-command script can report success while a middle command failed, which produces the worst kind of CI result: a green build that shipped something broken.

.github/workflows/ci.yml
# pipefail is NOT set by default in every runner shell
- name: Build
  shell: bash
  run: |
    set -euo pipefail
    npm run build | tee build.log

How to prevent it

  • Pin a Node version in CI so the default rejection mode does not change under you.
  • Lint for floating promises so rejections never go unhandled in the first place.
  • Keep --unhandled-rejections=strict and treat any rejection as a bug.

Frequently asked questions

What causes Node.js "--unhandled-rejections=strict" changed CI exit behavior?
There are 2 common causes: the default mode changed across node versions and node_options sets a stricter mode. Older Node warned on unhandled rejections; Node 15+ defaults to throw.
How do I fix Node.js "--unhandled-rejections=strict" changed CI exit behavior?
There are 2 fixes depending on which cause you have: fix the rejection rather than the mode and set the mode deliberately if you must. Work through them in order, since the first is the most common.
What does Node.js "--unhandled-rejections=strict" changed CI exit behavior actually mean?
After a Node upgrade or a changed NODE_OPTIONS, a job that previously only logged a warning now exits non-zero on the same unhandled rejection.
How do I stop Node.js "--unhandled-rejections=strict" changed CI exit behavior happening again?
Pin a Node version in CI so the default rejection mode does not change under you. The prevention section lists 3 changes that keep it from recurring.

Related guides

References

Not every red build is your code. Latchkey repairs the ones that are not, on the runner. Start free → 30-day trial · No credit card