The error names two ABI numbers, and the first one picks the fix: npm rebuild repairs a Node 24 binary on Node 26, makes an Electron binary worse, and does nothing for a Node-API addon, which never prints this error at all. Below is how to read the ABI straight out of each .node file, rebuild only the stale ones, and key the CI cache on the ABI so an old binary cannot come back. Node 26 reaches Active LTS on 28 October 2026 ("lts": "2026-10-28" in nodejs/Release schedule.json, with Node 24 dropping to Maintenance on 20 October), and nobody has to upgrade deliberately for this to land: FROM node:lts, node-version: lts/* and a PaaS default all resolve the word "LTS" at build time, as covered in how node:lts moves without a commit.
Prerequisites
- A Node 26 runtime in reach.
docker run --rm -it node:26 bashreproduces everything here. Confirm withnode -p process.versions.modules, which must print147. - GNU binutils (
nm) on Linux, or the Xcode command line tools on macOS. macOSnmprefixes symbols with an underscore and wantsnm -gUinstead ofnm -D. python3,makeand a C++17 compiler if you end up building from source. Thenode:26image has them;node:26-slimdoes not.- npm 12.x. If your estate is still mid-migration on install-script approvals, settle that first: an unapproved package turns
npm rebuildinto a command that exits 0 and compiles nothing, which is the same green-gate failure described in npm ESTRICTALLOWSCRIPTS: 3 ways the gate stays green. - Native addons worth checking:
better-sqlite3,sharp,bcrypt,canvas,node-pty,@parcel/watcher.
Step-by-step
1. Read both numbers in the error and map them to runtimes
Error: The module '/app/node_modules/better-sqlite3/build/Release/better_sqlite3.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION 137. This version of Node.js requires
NODE_MODULE_VERSION 147. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).
The second number is your runtime. The first names the runtime that produced the binary, and that is the number your fix depends on. The mapping below comes from Node's own doc/abi_version_registry.json, which is also where Electron's ABI numbers interleave with Node's.
| ABI reported | Built by | What it means here |
|---|---|---|
| 108 | Node 18 | Long-stale binary, usually vendored or committed into the repo |
| 115 | Node 20 | EOL line since 30 April 2026 |
| 127 | Node 22 | Reported in diegosouzapw/OmniRoute#3476 against Node v26.3.0, from a global install |
| 137 | Node 24 | The common case on the 28 October flip |
| 141 | Node 25 | A Current-line build leaked into the job |
| 142 / 144 | Node 26 pre-release | Reserved during development; a binary reporting these came from a nightly or an RC |
| 143 / 145 / 146 / 148 | Electron 40 / 41 / 42 / 43 | Not a Node rebuild problem, see the pitfalls below |
| 147 | Node 26 | Correct. If 147 is the first number, the runtime is older than the binary |
The value itself lives in src/node_version.h on the v26.x branch, which at the time of writing sits at NODE_MAJOR_VERSION 26 / NODE_MINOR_VERSION 12 / NODE_PATCH_VERSION 2.
The branch in src/node_binding.cc that prints the message is if ((mp->nm_version != -1) && (mp->nm_version != NODE_MODULE_VERSION)). Node-API addons register with nm_version == -1, so they are excluded by the first half of that condition and can never produce this error, no matter how broken they are on Node 26.
2. Inventory every .node file without executing it
find node_modules -name '*.node' -not -path '*/obj.target/*' -exec sh -c '
for f; do
sym=$(nm -D --defined-only "$f" 2>/dev/null \
| grep -oE "napi_register_module_v1|node_register_module_v[0-9]+" | head -1)
printf "%s\t%s\n" "${sym:-unknown}" "$f"
done' sh {} +
Node looks for a symbol named node_register_module_v${NODE_MODULE_VERSION}; the macro chain in src/node.h pastes NODE_MODULE_INITIALIZER_BASE node_register_module_v onto NODE_MODULE_VERSION, so the ABI is readable from the symbol table without loading anything. Node-API addons export napi_register_module_v1 instead, where the 1 is the Node-API registration ABI and stays stable across majors.
Three outcomes, three different jobs. napi_register_module_v1 survives the flip untouched. node_register_module_v137 needs a rebuild. unknown is either a stripped binary or a legacy self-registering addon that keeps its version in a struct rather than a symbol name, and step 3 handles those.
3. Probe the leftovers by loading them in a throwaway process
node -e '
const path = require("path");
const f = path.resolve(process.argv[1]);
try { process.dlopen({ exports: {} }, f); console.log("loads\t" + f); }
catch (e) {
const m = /NODE_MODULE_VERSION (\d+)/.exec(e.message);
console.log((m ? "built-for " + m[1] : e.code) + "\t" + f);
}' ./node_modules/<pkg>/build/Release/<pkg>.node
process.dlopen runs the addon's initializer, which is what require would have done, so keep this to your own tree and to a child process you can throw away. The number then comes out of the binary rather than out of a table.
A note on why I bother with behavioural probes at all. On Brotli patch day this month I tested my own box with the invalid-block-switch vector from Node's test/parallel/test-zlib-invalid-input.js and read the decoder's error code rather than running node -v. It returned errno=-9, unpatched, confirmed from the runtime instead of inferred from a version string, on an EOL Node 20 box where that line will never be patched. The upgrade off 20 is scoped as its own change, because two long-running services sit on that interpreter. The mechanism behind that day is written up in php2shell and Node Brotli RCE: the version string lies, and ABI deserves the same treatment.
4. Find the mismatches that never printed
node -e '
const { spawnSync } = require("child_process");
const pkg = require("./package.json");
for (const d of Object.keys({ ...pkg.dependencies, ...pkg.optionalDependencies })) {
const r = spawnSync(process.execPath, ["-e", `require(${JSON.stringify(d)})`], { encoding: "utf8" });
const hit = /ERR_DLOPEN_FAILED|NODE_MODULE_VERSION \d+/.exec(r.stderr || "");
if (hit) console.log(d + "\t" + hit[0]);
}'
Plenty of trees swallow this error. The loader that requires the addon sits inside a try/catch with a JS fallback, so the mismatch becomes a performance regression or a missing feature instead of a stack trace. In ruvnet/ruflo#3175 a Node 24 to 26 move with a reused npx cache produced a green "sql.js success" line and no error whatsoever. Requiring each dependency in its own process steps around the application's fallbacks. Filtering on ERR_DLOPEN_FAILED keeps ESM-only packages and noisy entry points out of the output.
5. Rebuild, and make the prebuild layer agree
node -p process.versions.modules # 147
npm ls node-abi # must resolve 26.0.0 -> 147
rm -rf "$(npm config get cache)/_prebuilds" # documented cache path
npm rebuild --foreground-scripts better-sqlite3
prebuild-install caches downloaded binaries under ${npm_config_cache}/_prebuilds per its documented options, so a stale tarball survives deleting node_modules and reinstalling. --foreground-scripts prints the compiler output, which is the only way to tell a real rebuild apart from a no-op that exits 0. When no prebuilt binary exists for 147 yet, force the source path:
npm_config_build_from_source=true npm rebuild better-sqlite3
6. Put the ABI in the CI cache key
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- id: abi
run: echo "modules=$(node -p process.versions.modules)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
with:
path: node_modules
key: nm-${{ runner.os }}-abi${{ steps.abi.outputs.modules }}-${{ hashFiles('package-lock.json') }}
A key built from the lockfile hash alone will happily restore ABI 137 binaries into a Node 26 job, and the lockfile hash does not change when the runtime does, so the failure survives a cache bust and looks like a flake. Pin the runtime in .nvmrc rather than lts/*. The runner's own Node is a separate moving part, covered in GitHub Actions Node 20 removal.
Verify it works
node -p process.versions.modules # 147
find node_modules -name '*.node' -not -path '*/obj.target/*' -exec sh -c '
for f; do nm -D --defined-only "$f" 2>/dev/null \
| grep -oE "napi_register_module_v1|node_register_module_v[0-9]+" | head -1; done' sh {} + \
| sort | uniq -c
Every counted line must be napi_register_module_v1 or node_register_module_v147. Any other node_register_module_v* is a binary that will throw on first use rather than at install time, which usually means in production. Then run the step 4 sweep again: a clean tree prints nothing.
Common pitfalls
- The first number is 143, 145, 146 or 148. Those are Electron 40 through 43 in the registry.
npm rebuildmakes it worse by flipping the message to "built 147, requires 146". Use@electron/rebuild, and keep Electron ABIs out of shared Node cache keys. - Node-API addons do not produce this error. A Node-API package that breaks on 26 gives you
Module did not self-register: '<file>'or the Node-API version message assembled innode_api.cc:<module> requires Node-API version <N>, but this version of Node.js only supports version <MAX> add-ons. Both mean upgrade the package. Could not detect abi for version 26.x.x and runtime node.That comes fromnode-abi, not from Node. ItsgetAbiwalks to the highest known target at or below your version and returns that ABI only while you are below the next known target, so a lockfile-pinned old copy throws rather than guessing. Refreshnode-abiand its lockfile entry; do not hand-force an ABI.- Multi-stage Docker with two different bases. A builder on
node:24and a runtime onnode:ltswere consistent until 28 October and quietly stopped being consistent afterwards. Build and run the same major, pinned by digest. - Global and npx caches. Both ruflo#3175 and OmniRoute#3476 are cache problems rather than dependency-tree problems.
npm cache clean --forceleaves_prebuildsalone, andnpxkeeps a cache of its own. node:sqlitedeletes one of these cases. It ships inside the runtime and has no ABI to mismatch, which is worth knowing if you are carryingbetter-sqlite3only for simple storage. The API is not a drop-in replacement.
FAQ
Does npm install fix this as well as npm rebuild? Only if the install actually recompiles. With a prebuilt binary available for your platform, npm install fetches a tarball that may still be cached under _prebuilds, so the stale artifact comes straight back. Delete that directory or set npm_config_build_from_source=true.
Can I keep one node_modules shared between Node 24 and Node 26 jobs? Not for native addons. A single tree holds one compiled .node per package, and the ABI is baked in, so the cache key has to include process.versions.modules.
Why does the inventory show unknown for a file that loads fine? The binary is stripped, or it is a legacy self-registering addon that stores its version in a struct rather than in the symbol name. Step 3 settles it by loading the file in a throwaway process.
Is ABI 147 final for the whole Node 26 line? Yes for releases on v26.x: NODE_MODULE_VERSION is fixed per major in src/node_version.h. The registry reserved 142 and 144 during Node 26 development, so a binary reporting either came from a nightly or an RC and should be rebuilt.
Wrap-up
You now have the ABI of every binary in the tree, a rebuild scoped to the stale ones, and a cache key that cannot resurrect them. Promote the step 2 sweep from a report to an assertion in CI, failing the job on any node_register_module_v other than 147. Delete that gate once the same sweep shows nothing but napi_register_module_v1, because at that point there is no ABI left to mismatch.
Comments
Be the first to comment.