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 bash reproduces everything here. Confirm with node -p process.versions.modules, which must print 147.
  • GNU binutils (nm) on Linux, or the Xcode command line tools on macOS. macOS nm prefixes symbols with an underscore and wants nm -gU instead of nm -D.
  • python3, make and a C++17 compiler if you end up building from source. The node:26 image has them; node:26-slim does not.
  • npm 12.x. If your estate is still mid-migration on install-script approvals, settle that first: an unapproved package turns npm rebuild into 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 reportedBuilt byWhat it means here
108Node 18Long-stale binary, usually vendored or committed into the repo
115Node 20EOL line since 30 April 2026
127Node 22Reported in diegosouzapw/OmniRoute#3476 against Node v26.3.0, from a global install
137Node 24The common case on the 28 October flip
141Node 25A Current-line build leaked into the job
142 / 144Node 26 pre-releaseReserved during development; a binary reporting these came from a nightly or an RC
143 / 145 / 146 / 148Electron 40 / 41 / 42 / 43Not a Node rebuild problem, see the pitfalls below
147Node 26Correct. 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.

Addon fails on Node 26Did the error print?Require each dependencyin its own processFirst ABI numberStale Node buildnpm rebuild the packageElectron 40 to 43use @electron/rebuildRuntime older than binaryupgrade the runtimenm shows napi_register_module_v1Node-API addonrebuild changes nothingupgrade the package"no""yes""108 115 127 137 141""143 145 146 148""147"

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 rebuild makes 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 in node_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 from node-abi, not from Node. Its getAbi walks 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. Refresh node-abi and its lockfile entry; do not hand-force an ABI.
  • Multi-stage Docker with two different bases. A builder on node:24 and a runtime on node:lts were 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 --force leaves _prebuilds alone, and npx keeps a cache of its own.
  • node:sqlite deletes one of these cases. It ships inside the runtime and has no ABI to mismatch, which is worth knowing if you are carrying better-sqlite3 only 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.