Use node --run for scripts you start by hand and npm run anywhere a machine reads the result. node --run starts a package.json script roughly 60 ms faster, but it skips pre and post hooks, sets none of npm’s npm_* variables, turns every non-zero exit code into 1, and reports a script killed by a signal as a success.
The last of those is undocumented, and it can turn a killed CI job green. We ran every behaviour below on macOS (Apple Silicon) against three Node releases with the npm each one bundles: Node 22.23.2 with npm 10.9.8, Node 24.22.0 with npm 11.19.1, and Node 26.13.0 with npm 11.20.0. Nothing was run on Linux or Windows; what we say about those comes from the Node source.
--run was added in Node 22.0.0 and marked stable in Node 22.10.0, released in October 2024. The CLI documentation is upfront that it “is not meant to match the behaviors of npm run”, so the work is finding out which of the gaps your scripts rely on.
What does node —run skip that npm run does?
The docs list two deliberate omissions: pre and post scripts, and package manager environment variables. Projects tend to depend on both without anyone having written it down.
With prebuild, build and postbuild defined, npm run build printed all three. node --run build printed only build, on all three Node versions. If your prebuild cleans dist/ or generates types, switching runners silently drops that step. Chain it explicitly instead, which works under both:
{
"scripts": {
"clean": "rm -rf dist",
"build": "node --run clean && vite build"
}
}
The environment is the second gap. Under npm run, a script saw npm_lifecycle_event=env and npm_package_version=1.4.0. Under node --run, both were empty. In their place Node sets two of its own: NODE_RUN_SCRIPT_NAME (the script name) and NODE_RUN_PACKAGE_JSON_PATH (the absolute path of the package.json it found). Any script that stamps a build with $npm_package_version needs changing.
A third gap catches people who read our post on loading .env files without dotenv. node --env-file=vars.txt --run dotenv left the variable empty in all three versions. The CLI docs for 24.22.0 and 26.13.0 say so outright; the 22.23.2 docs do not mention it, but the behaviour was the same.
Two more are differences of scope. npm’s run-script docs describe --workspace and --workspaces flags; the --run docs describe no workspace option. And a script whose body calls npm run lint still starts npm for that inner call, so the time saving only applies to the outer layer.
Things that do match: both runners walked up from a subdirectory to the nearest package.json, both ran the script with that package.json’s folder as the working directory, and both put node_modules/.bin on PATH. Arguments after -- are passed through under both, with one Node 22 exception covered below.
Is node —run faster than npm run?
Yes. We timed a script whose body was true, taking the median of 20 runs after two warm-ups:
| Node | node --run noop | npm run noop | node -e 0 |
|---|---|---|---|
| 22.23.2 | 12 ms | 76 ms | 19 ms |
| 24.22.0 | 13 ms | 76 ms | 20 ms |
| 26.13.0 | 14 ms | 76 ms | 21 ms |
node --run even came in a few milliseconds under node -e 0. The task runner was rewritten in C++ in 2024, while npm is a JavaScript program that Node has to load and start before it reads your package.json.
Sixty milliseconds is lost in a build that takes a minute. You notice it in a lint-on-save hook, a git hook, or an editor task you trigger dozens of times an hour.
Why does node —run report a killed script as success?
None of this is in the documentation. This reproduction runs on any Node version you have installed:
{
"scripts": {
"fail3": "exit 3",
"killed": "node -e \"process.kill(process.pid, 'SIGKILL')\""
}
}
node --run fail3; echo "node --run fail3: $?"
npm run fail3; echo "npm run fail3: $?"
node --run killed; echo "node --run killed: $?"
npm run killed; echo "npm run killed: $?"
On all three versions we got the same four numbers. npm run fail3 exited 3 and node --run fail3 exited 1. npm run killed exited 137 (128 plus SIGKILL’s 9), and node --run killed exited 0. The same script sending itself SIGTERM gave 143 under npm and 0 under node --run.
The source explains both. In node_task_runner.cc, the exit callback receives both an exit status and a terminating signal, then maps any status above zero to a generic error and everything else to success. The signal is never consulted. For a child killed by a signal, the reported exit status is 0 and only the signal field is set, so it falls through to success.
So if anything ends a test run with a signal (a memory limit enforced with SIGKILL, a timeout that sends SIGTERM), and the CI step was node --run test, the step reports exit 0. CI treats that step as passed unless something else notices the missing output.
The flattening to 1 is a smaller problem, but real if a wrapper script branches on specific codes, such as a linter that uses 1 for findings and 2 for a crash.
Does node —run forward SIGTERM to the script?
Not in our test. We used a script that sent SIGTERM to its own parent, then slept and wrote a marker file. Under node --run the parent exited with 143 and the script carried on and wrote the file. Under npm run the script was stopped and no file appeared. npm forwards SIGINT and SIGTERM to its children; you can see the list in @npmcli/run-script’s signal-manager.js.
An open Node issue from September 2026 asks for exactly this, describing the shell in the middle swallowing signals and lingering when node --run is PID 1 in Docker. We did not test Docker. Our observation is narrower: a SIGTERM sent to the node --run process alone did not reach the script.
We did not test Ctrl-C. In a terminal it goes to the whole foreground process group, so the script should receive it directly, but a supervisor or orchestrator that signals one PID is the case our test covers.
Which Node versions behave differently?
Two behaviours changed between the versions we ran.
Running node --run with no script name printed --run requires an argument on 22.23.2. On 24.22.0 and 26.13.0 it listed the available scripts. All three exited with code 9. The changelog places the listing in Node 24.22.0 and 26.9.0.
Passing an argument containing a single quote broke on 22.23.2. node --run args -- "it's" produced /bin/sh: -c: line 0: unexpected EOF while looking for matching and the script never ran. On 24.22.0 and 26.13.0 the argument arrived intact. The fix, “fix escaping of single quotes in task runner”, is listed in Node 24.19.0; we found no matching entry in the Node 22 changelog.
On Windows, the source shows --run launching the shell named by the ComSpec variable with /d /s /c, and /bin/sh -c everywhere else. npm lets you choose with its script-shell setting. We did not run any of this on Windows.
How do you read package.json from a script under either runner?
To read the package version under either runner, read the file whose path each runner hands you. npm sets npm_package_json; node --run sets NODE_RUN_PACKAGE_JSON_PATH.
// pkg-version.mjs
import { readFileSync } from 'node:fs';
// node --run sets NODE_RUN_PACKAGE_JSON_PATH; npm run sets npm_package_json.
const pkgPath =
process.env.NODE_RUN_PACKAGE_JSON_PATH ?? process.env.npm_package_json;
if (!pkgPath) {
console.error('Run this through node --run or npm run.');
process.exit(1);
}
const { name, version } = JSON.parse(readFileSync(pkgPath, 'utf8'));
console.log(`${name}@${version}`);
With "pkg-version": "node pkg-version.mjs" in scripts, this printed [email protected] under both runners on all three versions, including from a subdirectory. Run directly with node pkg-version.mjs, it exits 1 with the message.
What we would do
Use node --run for the things a person starts and watches: node --run dev, node --run lint, git hooks, editor tasks. Move any pre and post logic into explicit && chains first, and replace npm_package_* reads with something like the script above. If you already use node --watch in place of nodemon, as in our comparison of the two, this is the same trade: fewer moving parts, fewer features.
Keep npm run (or call the command directly) in CI steps, container entrypoints and anything else where a machine decides success from the exit code, until a Node release reports signal deaths properly. Check that against your version with the reproduction above rather than trusting this post, because a fix could land in any release. If you are still on Node 22, do not pass arguments containing a single quote through --run.
We build and maintain Node.js services and the CI around them, including the build scripts that quietly decide whether a deploy goes out. Our Node.js development page covers that work.