Node.jsDevOps

Node.js 26 LTS Migration Guide: Move From 22 or 24 Without Surprises

November 3, 2026
14 min read
Close-up of black rack servers with green status LEDs and a steel handle, lit in cool blue light
Share:

On 28 October 2026, Node.js 26.13.0 ‘Lithium’ moved the 26.x line to Active LTS, with support through the end of April 2029. The release changes nothing but metadata such as process.release: the code is the same as 26.12.1, Node2Shell fix included. What makes it matter is everything that follows the LTS label. That same day the official Docker node:lts tag jumped from 24 to 26, setup-node’s lts/* started resolving to 26.13.0, and on 29 October Vercel made 26 the default for new projects.

This guide takes a project from Node 22 or 24 (or the already dead 20) to 26 in five steps: inventory every pin, upgrade, fix what breaks, adopt what’s worth it, and plan for the new once-a-year schedule. On 3 November I downloaded the official 26.13.0 Windows build, checked its SHA-256, and tested every JavaScript snippet below on it and on my local 22.22.0. The CI, Docker and platform steps come from each project’s docs that day; I didn’t run a workflow or a Docker build.

1. The Problem: Your Node Version Lives in a Dozen Places

The 26.13.0 release notes are four sentences long, and that’s the point: an LTS promotion is a support promise, not a feature drop. The breaking changes arrived on 5 May with Node.js 26.0.0 (V8 14.6, Temporal on by default, Undici 8 and a round of removals), plus everything Node 25 removed if you’re coming from 24.

The hard part of a Node upgrade is rarely the code. It’s the version pinned in .nvmrc, in package.json, in three Dockerfiles, a CI matrix, a compose file, a Lambda runtime string and a dashboard setting nobody remembers. Miss one and you get the worst of both worlds: tests pass on 26 while production still runs 22, or the other way around.

🟢

Active LTS until

20 Oct 2027

Then Maintenance LTS; end of life on 30 Apr 2029

⚙️

Engine

V8 14.6

Plus Undici 8.12.2 and npm 11.20.0 in 26.13.0

🧩

Native ABI

147

NODE_MODULE_VERSION: 127 on 22, 137 on 24. Rebuild addons

🪦

Node.js 20

EOL

Since 30 Apr 2026: no more fixes, Node2Shell included

Floating pins already moved without you

If you use any of these, part of your fleet may already run 26: Docker’s node:lts, node:lts-slim and node:latest (26.13.0 since 28 October, and the default and slim tags also switched the base from Debian 12 bookworm to Debian 13 trixie); lts/* in setup-node or nvm; and a Vercel engines range such as >=20, which builds with 26 on the next deployment. Decide whether that’s what you want before your next deploy decides for you.

Still on Node 20? This part is urgent

Node 20 reached end of life on 30 April 2026. It never got a fix for CVE-2026-111497, the Brotli bug from my Node2Shell patch guide, Vercel stopped accepting new deployments on Node 20 on 1 October, and AWS deprecated its nodejs20.x Lambda runtime on 30 April. Skip 22, which has about six months left, and go straight to 26, or to 24 if a dependency blocks you.

2. The Minimum Concepts

Five ideas explain every step that follows.

📅

Active vs Maintenance LTS

Active LTS gets audited backports, new features included; Maintenance gets only critical fixes and security updates. Under the old model each even line spent 12 months in Active and 18 in Maintenance. Per nodejs.org, production should only run one of the two.

🏷️

What 26.13.0 actually is

A semver-minor release with no code changes from 26.12.1. The visible difference is process.release.lts, which reads ‘Lithium’ on the build I downloaded: the quickest way to tell an LTS build from a Current one.

🧱

You cross every major in between

From 24 you also get Node 25’s semver-majors: SlowBuffer, fs.F_OK and friends, rmdir’s recursive option and assert.CallTracker are gone, and Corepack is no longer bundled. From 22, add 24’s list from the official v22-to-v24 guide; OpenSSL’s security level 2, for one, rejects RSA, DSA and DH keys under 2048 bits.

⚠️

Three levels of deprecation

Documentation-only (no warning), runtime (a warning with a DEP code) and End-of-Life (gone). Node 26 moved four deprecations to End-of-Life and made four more runtime ones, module.register() among them. --throw-deprecation turns runtime warnings into crashes, which is what you want in CI.

🔌

The native ABI

Compiled addons are tied to NODE_MODULE_VERSION, 147 on Node 26, so anything built for 22 or 24 must be rebuilt. Packages built on Node-API survive majors without a rebuild, because that ABI is stable.

Where every release line stands (3 Nov 2026)

LineCodenameStatusLatestMaintenance fromEnd of life
20IronEnd-of-life20.20.2 (24 Mar 2026)22 Oct 202430 Apr 2026
22JodMaintenance LTS22.23.421 Oct 202530 Apr 2027
24KryptonMaintenance LTS24.22.220 Oct 202630 Apr 2028
26LithiumActive LTS26.13.020 Oct 202730 Apr 2029
27—Alpha27.0.0-alpha.0.0.0 (29 Oct)None: Active LTS only30 Apr 2030

Dates from the nodejs/Release schedule and its schedule.json; latest versions from the nodejs.org dist index. 24.22.2 is a regular update with V8 backports. Any version at or above 22.23.4, 24.22.1 or 26.12.1 carries the Node2Shell fix.

3. Step 1: Inventory Every Pin

Start with a list, not an upgrade. This script reads only the files git tracks and prints every Node version it can find: version files, package.json fields, Dockerfiles and compose files, CI configs, Netlify’s NODE_VERSION and serverless runtime strings. It runs on the Node you have today and changes nothing.

node-pins.mjs (Node 18+, read-only)

// node-pins.mjs: list every place a repo pins a Node.js version (read-only).
// Run it from the repo root with the Node.js you have today (18+): node node-pins.mjs
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { basename } from "node:path";

const files = execFileSync("git", ["ls-files"], { encoding: "utf8" }).split("\n").filter(Boolean);
const hits = [];
const add = (file, where, text) => hits.push(`${file}:${where}  ${text.trim()}`);

// [which files, which lines inside them]
const RULES = [
  [/(^|\/)(\.tool-versions|\.?mise\.toml)$/, /^\s*node(js)?\b/],
  [/(^|\/)(Dockerfile[^/]*|[^/]*\.dockerfile|(docker-)?compose[^/]*\.ya?ml)$/i, /\bnode:[\w.-]+/],
  [/\.(ya?ml|toml|json|tf)$/, /node-version|NODE_VERSION|nodejs\d+\.x|\bnode:\s*\[|image:\s*["']?node:/],
];

for (const file of files) {
  const name = basename(file);
  if (file.includes("node_modules/") || name === "package-lock.json") continue;
  let text;
  try { text = readFileSync(file, "utf8"); } catch { continue; }

  if (name === ".nvmrc" || name === ".node-version") { add(file, 1, text.split(/\r?\n/)[0]); continue; }

  if (name === "package.json") {
    let pkg;
    try { pkg = JSON.parse(text); } catch { continue; }
    if (pkg.engines?.node) add(file, "engines.node", pkg.engines.node);
    if (pkg.volta?.node) add(file, "volta.node", pkg.volta.node);
    const rt = [pkg.devEngines?.runtime].flat().find((r) => r?.name === "node");
    if (rt) add(file, "devEngines.runtime", rt.version ?? "(any)");
    continue;
  }

  const rule = RULES.find(([fileRe]) => fileRe.test(file));
  if (!rule) continue;
  text.split(/\r?\n/).forEach((line, i) => rule[1].test(line) && add(file, i + 1, line));
}

console.log(hits.length ? hits.join("\n") : "No Node.js pins found in tracked files.");

Its output on a test repo I seeded with pins (trimmed)

$ node node-pins.mjs
.github/workflows/ci.yml:5  node: [20, 22, 24]
.github/workflows/ci.yml:9  node-version: ${{ matrix.node }}
.gitlab-ci.yml:1  image: node:22
.nvmrc:1  24
Dockerfile:1  FROM node:lts-slim AS build
Dockerfile:3  FROM node:24-alpine
api/Dockerfile.dev:1  FROM --platform=linux/amd64 node:20.20.2-bookworm
api/package.json:engines.node  22.x
compose.yaml:3  image: node:24-bookworm-slim
mise.toml:2  node = "24"
netlify.toml:2  NODE_VERSION = "24"
package.json:engines.node  >=20
package.json:volta.node  24.22.1
serverless.yml:3  runtime: nodejs20.x
web/.node-version:1  v22.20.0

What a script can’t see

Dashboard settings live outside git: Vercel’s Node.js Version setting, Netlify’s Dependency management, hand-made Lambda functions, CI runners with a system Node. On this site’s repo the script found no pins at all, so the Vercel project setting decides. Add those by hand, then commit one source of truth: .nvmrc (read by nvm, fnm, Netlify and setup-node’s node-version-file) plus engines.node for Vercel.

4. Step 2: Upgrade Laptops, CI and Images

Upgrade in this order: your machine, then CI with 26 running next to what production runs today, then images and platforms. Each block below changes one layer. For an app, engines.node set to 26.x is the pin Vercel reads; a library should keep a range that matches what it tests.

Local: pick your version manager, then pin the project

# nvm (macOS, Linux, WSL): install 26 and copy your global npm packages from 24
nvm install --reinstall-packages-from=24 26
nvm alias default 26

# fnm (Windows too)
fnm install 26
fnm default 26

# mise: writes node = "26" to the project's mise.toml
mise use node@26

# Volta: writes volta.node to package.json (its README says Volta is unmaintained)
volta pin node@26

# Then pin the project for every other tool, and check what you got
echo "26" > .nvmrc
npm pkg set engines.node="26.x"
npm install -g corepack            # only if you use pnpm or Yarn 4: Node 25+ dropped it
node -p "process.version + ' ' + process.release.lts"   # v26.13.0 Lithium

CI: add 26 to the matrix before you switch production

# .github/workflows/node-26.yml: the same suite on every supported line
name: node-26
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false        # see every line's result, not just the first failure
      matrix:
        node: [22, 24, 26]    # 20 is end-of-life: drop it
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci
      - run: npm test
        env:
          # On 26 only: every runtime deprecation becomes a crash
          NODE_OPTIONS: ${{ matrix.node == 26 && '--throw-deprecation' || '' }}

Docker: change one variable at a time

# Before: FROM node:24-bookworm-slim, or node:lts-slim (26 on Debian trixie since 28 Oct)
# 1. Change one thing at a time: same Debian release, new Node major
FROM node:26.13.0-bookworm-slim
# 2. In a separate PR, move the OS too:  FROM node:26.13.0-trixie-slim
#    Pin the digest as well: docker buildx imagetools inspect node:26.13.0-bookworm-slim

# Node 26 images no longer ship Yarn v1. Pick ONE of these if you need a package manager:
RUN npm install --global yarn@1.22.22
# RUN npm install --global corepack@0.36.0 && corepack enable   # pnpm / Yarn 4

# Fail the build early if the base is not what you think
RUN node -p "process.version + ' ' + process.release.lts"

Hosted platforms, checked on 3 Nov 2026

PlatformNode 26 statusHow you select itWatch out for
VercelGA for builds and Functions since 29 Oct; default for new projectsengines.node “26.x”, or Settings → Build and Deployment → Node.js Version (engines wins)Ranges like >=20 move to 26 on the next deploy; 22.x and 24.x stay available
NetlifyBuilds take any version nvm can install; the build image default is 24.nvmrc, then .node-version, then NODE_VERSION, then the UIFunctions follow the build version unless you set AWS_LAMBDA_JS_RUNTIME; the pages I read don’t mention 26
AWS Lambdanodejs26.x in public preview; general availability targeted for November 2026The runtime identifier, nodejs26.xPreview runtimes aren’t covered by the Lambda SLA: keep production on nodejs24.x for now
Docker Hub (official node)node:26, node:lts and node:latest point to 26.13.0 since 28 OctAn explicit tag such as node:26.13.0-bookworm-slim, ideally with its digestDefault and slim tags are Debian trixie; Alpine only for amd64 and arm64; no Yarn v1
GitHub Actionssetup-node@v7 resolves 26 and lts/* to 26.13.0node-version, or node-version-file (.nvmrc, .node-version, package.json, mise.toml)lts/* moved on its own on 28 Oct

Corepack and Yarn v1 are gone

Node stopped bundling Corepack in 25.0.0, so a laptop or runner that jumps from 22 or 24 to 26 loses the corepack command, and with it the pnpm and Yarn shims. Install it from npm (npm install -g corepack), as the Corepack README says. Separately, the official images no longer bundle Yarn v1 from Node 26 on: I compared the 24 and 26 Dockerfiles in nodejs/docker-node, and only the 24 one installs Yarn 1.22.22.

5. Step 3: Find What Breaks and Fix It

Two passes catch almost everything: a static search for APIs that no longer exist, then your test suite on 26 with deprecations turned into errors. The 26.0.0 changelog and the deprecations page have the full lists; the table keeps what an app or a library is likely to hit.

Static pass: search your code and your dependencies

# 1. APIs removed in 25 and 26 (git grep only searches tracked files)
git grep -nE "_stream_(readable|writable|duplex|transform|passthrough|wrap)|\.writeHeader\(|\bSlowBuffer\b|fs\.[FRWX]_OK\b|rmdir(Sync)?\(.*recursive|experimental-transform-types"

# 2. Runtime-deprecated in 26: a warning today, an error in a later major
git grep -nE "module\.register\(|\{[^}]*\bregister\b[^}]*\} from ['\"](node:)?module|Duplex\.toWeb\("

# 3. The stream change: review every bare read() on a Readable
git grep -nE "\.read\(\)" -- "*.js" "*.mjs" "*.cjs" "*.ts"

# 4. Dependencies: a known breakage, and native addons to rebuild on 26
npm ls yargs                     # needs 16.2.2+, 17.7.3+ or 18
find node_modules -name "*.node" -not -path "*/test/*"

Breaking changes worth checking on the way to 26

ChangeSinceWhat breaksFix
_stream_readable, _stream_writable, _stream_duplex, _stream_transform, _stream_passthrough and _stream_wrap removed (DEP0193, DEP0125)26.0.0require() throws MODULE_NOT_FOUND, or ERR_UNKNOWN_BUILTIN_MODULE with the node: prefixImport Readable, Writable, Duplex, Transform or PassThrough from node:stream
res.writeHeader() removed (DEP0063)26.0.0TypeError: it’s no longer a functionres.writeHead(), same arguments
Readable read() returns one buffer at a time26.0.0Code that calls read() once per ‘readable’ event gets a fragment and the stream can stall; for await yields one chunk per push()Drain in a loop, call read(readable.readableLength) or collect with node:stream/consumers
--experimental-transform-types removed26.0.0“bad option” at startup; enums and namespaces fail with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAXWrite erasable TypeScript (tsc’s erasableSyntaxOnly flags the rest) or keep a loader or a build step
Short GCM auth tags without authTagLength (DEP0182)26.0.0decipher.setAuthTag() rejects AES-GCM tags shorter than 16 bytesPass authTagLength to crypto.createDecipheriv()
Extensionless files in “type”: “module” packages load as ESM26.0.0ReferenceError: require is not defined in ES module scope, e.g. yargs 16.2.0 and 17.7.2Update the dependency: yargs 16.2.2, 17.7.3 or 18
SlowBuffer, fs.F_OK/R_OK/W_OK/X_OK, rmdir’s recursive option and assert.CallTracker removed25.0.0undefined values, or ERR_INVALID_ARG_VALUE from rmdirSync()Buffer.allocUnsafeSlow(), fs.constants.F_OK, fs.rm(path, { recursive: true }); codemods below
module.register() (DEP0205), CryptoKey in node:crypto (DEP0203, DEP0204), Duplex.toWeb() type (DEP0201)26.0.0, runtimeWarnings today; the docs say they’ll throw or disappear in a future versionmodule.registerHooks(), KeyObject.from() for extractable keys or Web Crypto, readableType

The stream change, reproduced

I pushed three buffers into a Readable and read it the way many hand-written parsers do: one read() per ‘readable’ event. On 22.22.0 that read returned all nine bytes and the stream ended. On 26.13.0 it returned the first three, left six in the buffer, and ‘end’ never fired. for await went from one concatenated chunk to three, while ‘data’ events were identical on both. The change comes from PR #60441, which skips concatenating buffers on every read.

Before and after (the three fixes tested on 26.13.0)

import { text } from "node:stream/consumers";

// Before: one read() per 'readable' event. On 26 it gets ONE buffer and can stall forever
stream.on("readable", () => {
  const chunk = stream.read();
  if (chunk) handle(chunk);
});

// Fix 1: drain the buffer on every event (the pattern the stream docs use)
stream.on("readable", () => {
  let chunk;
  while ((chunk = stream.read()) !== null) handle(chunk);
});

// Fix 2: ask for everything buffered, as the PR suggests for the old behavior
const all = stream.read(stream.readableLength);

// Fix 3: for await now yields one chunk per push(); collect when you need the whole body
const body = await text(stream);   // also buffer(), json(), arrayBuffer()

Dynamic pass: tests on 26 with deprecations as errors

With NODE_OPTIONS=--throw-deprecation, my module.register() test printed its output and then exited with code 1 on DEP0205, so a CI step fails instead of hiding a warning in the log. Locally, --trace-deprecation prints the caller’s stack and --pending-deprecation shows warnings Node keeps off by default. The current tsx (4.23.15) didn’t trigger DEP0205 when I ran a .ts file through it with that flag.

Official codemods: useful for 25’s removals, not for 26’s

The nodejs/userland-migrations repo publishes codemods to the Codemod registry. I cloned it on 3 November: there’s a v22-to-v24 meta-recipe and single recipes for 25’s removals, but nothing yet for writeHeader, the _stream_* modules or module.register(), and no v24-to-v26 recipe. nodejs.org has no 24-to-26 migration guide either. I didn’t run them; they rewrite files, so commit first.

Recipes that apply on the way to 26

# Official Node.js codemods (nodejs/userland-migrations). Commit first: they rewrite files.
npx codemod @nodejs/v22-to-v24                                # coming from 22
npx codemod @nodejs/slow-buffer-to-buffer-alloc-unsafe-slow   # DEP0030, removed in 25
npx codemod @nodejs/fs-access-mode-constants                  # DEP0176, removed in 25
npx codemod @nodejs/rmdir                                     # DEP0147, removed in 25

6. Step 4: Adopt What’s Worth It

None of this is required to migrate, but three changes affect the code you write next week.

Temporal with no flag and no polyfill (temporal.mjs)

// temporal.mjs: node temporal.mjs on Node.js 26 (no flag, no polyfill)
if (typeof Temporal === "undefined") {
  throw new Error(`Temporal is not available on ${process.version}: use Node.js 26+`);
}

// 1. A calendar date: no time, no time zone, no off-by-one at midnight UTC
const due = Temporal.PlainDate.from("2027-04-30");            // Node.js 22 end-of-life
const today = Temporal.Now.plainDateISO("UTC");
console.log("days left on Node 22:", today.until(due, { largestUnit: "day" }).days);
// 178 when I ran it on 3 Nov 2026

// 2. Month arithmetic clamps instead of overflowing into March like Date does
console.log(Temporal.PlainDate.from("2026-01-31").add({ months: 1 }).toString());
// 2026-02-28

// 3. A meeting in one time zone, shown in another
const call = Temporal.ZonedDateTime.from("2026-11-10T09:00[America/Montevideo]");
console.log(call.withTimeZone("Europe/Madrid").toString());
// 2026-11-10T13:00:00+01:00[Europe/Madrid]

// 4. DST-aware: +1 day keeps the wall-clock time, +24 hours doesn't
const beforeDst = Temporal.ZonedDateTime.from("2027-03-27T12:00[Europe/Madrid]");
console.log(beforeDst.add({ days: 1 }).toString());    // 2027-03-28T12:00:00+02:00[Europe/Madrid]
console.log(beforeDst.add({ hours: 24 }).toString());  // 2027-03-28T13:00:00+02:00[Europe/Madrid]

// 5. Interop: a legacy Date in, Temporal out, and back
const instant = new Date("2026-10-28T16:57:41Z").toTemporalInstant();
console.log(instant.toZonedDateTimeISO("UTC").toPlainDate().toString()); // 2026-10-28
console.log(new Date(instant.epochMilliseconds).toISOString());          // 2026-10-28T16:57:41.000Z

Temporal replaces Date’s worst habits with explicit types: PlainDate for calendar dates, ZonedDateTime for a moment in a named time zone, Instant for a point in time, Duration for spans. The comments show what 26.13.0 printed on 3 November; on 22.22.0 the guard throws, since Temporal isn’t on by default before 26. Code that still runs on 22 or 24 needs a polyfill or that guard.

V8 14.6: upsert and iterator sequencing

// Upsert: no more "if (!map.has(k)) map.set(k, [])"
const byStatus = new Map();
for (const [status, url] of [[200, "/a"], [404, "/b"], [200, "/c"]]) {
  byStatus.getOrInsert(status, []).push(url);
}
console.log(byStatus); // Map(2) { 200 => [ '/a', '/c' ], 404 => [ '/b' ] }

// getOrInsertComputed only runs the callback on a miss
const cache = new Map();
const slow = (k) => { console.log("computing", k); return k.length; };
cache.getOrInsertComputed("lithium", slow); // logs "computing lithium"
cache.getOrInsertComputed("lithium", slow); // cached: no log
console.log(cache.get("lithium"));          // 7

// Iterator sequencing: chain iterables lazily, without spreading them into arrays first
const lines = Iterator.concat(["# header"], new Set(["a", "b"]), ["# footer"]);
console.log([...lines]); // [ '# header', 'a', 'b', '# footer' ]

fetch() now speaks HTTP/2

Undici 8 offers h2 during the TLS handshake, and Node 26’s fetch() uses it whenever the server agrees: against the same HTTPS host, 26.13.0 negotiated h2 and 22.22.0 http/1.1. Vercel’s announcement pitches it as connection sharing for functions that call APIs or databases. If an upstream misbehaves over HTTP/2, the switch is allowH2: false, documented in Undici’s v7-to-v8 guide. I confirmed that setting it on a global undici 8 Agent brought Node’s own fetch() back to http/1.1.

Check the protocol, and opt out only if you must

// check-h2.mjs: which protocol does fetch() negotiate?
import dc from "node:diagnostics_channel";
dc.subscribe("undici:client:connected", ({ socket }) => console.log(socket.alpnProtocol));
await fetch("https://www.cloudflare.com/", { method: "HEAD" });
// h2 on 26.13.0 (undici 8.12.2), http/1.1 on 22.22.0 (undici 6.23.0)

// force-h1.mjs: back to HTTP/1.1 for every fetch() in the process (npm i undici@8)
import { Agent, setGlobalDispatcher } from "undici";
setGlobalDispatcher(new Agent({ allowH2: false }));
// On Vercel Functions: set VERCEL_FETCH_DISABLE_H2=1 and redeploy instead

7. Step 5: Plan for One Major a Year

Node 26 is the last line under the old odd/even model. From 27 on, per the release schedule announcement, a line is branched every October, spends six months in Alpha (semver-major changes allowed), ships x.0.0 in April and becomes LTS in October for 30 months: 36 months in total. Every line becomes LTS, and there’s no Maintenance phase anymore. The Node team’s reasons: odd releases saw little adoption, and four or five active lines were too much for volunteers to keep patched.

LineAlphaCurrent (x.0.0)LTSEnd of life
26 (old model)—5 May 202628 Oct 202630 Apr 2029
27Oct 202622 Apr 202720 Oct 202730 Apr 2030
28Oct 2027Apr 2028Oct 2028Apr 2031

How I’d plan upgrades from here

  • Every November: the newest line has just become LTS: move production to it, or at least add it to CI.
  • Every April: the oldest LTS line reaches end of life (22 on 30 April 2027, 24 on 30 April 2028). Nothing should still run on it.
  • Apps: jumping every two years still works (26 and 28 overlap by six months), but yearly moves are smaller.
  • Libraries: test the Alpha in CI as an allowed failure. The first 27 Alpha already ships V8 15.6 and OpenSSL 4.1.0, per the nodejs.org dist index: a year of warning for TLS and crypto code.
  • Pins: exact versions where you deploy (tag plus digest, engines.node 26.x); floating aliases like lts/* only where a surprise is cheap.

Alpha isn’t for production

Per the Release README, Alphas are signed, tagged and tested through CITGM, but their API and ABI may change between releases; they’re for library authors and CI pipelines. The released setup-node and nvm docs I read don’t cover Alphas yet, so for now that’s a manual download. And all these dates are “subject to change”, in the Release team’s words.

8. Common Errors (and the Fix for Each)

The failures I’d expect in the first week, each traced to a changelog, a Dockerfile or a test I ran.

yarn: not found in a Docker build

Symptom: RUN yarn install fails right after you change FROM node:24 to FROM node:26, or after node:lts moved on 28 October.

Fix: Node 26 images don’t bundle Yarn v1. Install it explicitly (npm install --global yarn@1.22.22), or move to Corepack with a packageManager field.

corepack: command not found

Symptom: pnpm or Yarn 4 stops working on a laptop or CI runner after the switch, because Node 25 and later don’t ship Corepack.

Fix: npm install -g corepack, then corepack enable. In CI, make it a step before you install dependencies.

The image changed more than Node

Symptom: apt-get steps or system libraries behave differently after a rebuild on node:lts or node:26, because the default and slim tags moved from Debian 12 bookworm to Debian 13 trixie.

Fix: Pin the OS in the tag (node:26.13.0-bookworm-slim) for the Node upgrade, and move to trixie in a separate change.

NODE_MODULE_VERSION mismatch

Symptom: “was compiled against a different Node.js version using NODE_MODULE_VERSION 137. This version of Node.js requires NODE_MODULE_VERSION 147.”

Fix: Reinstall on 26 (npm ci or npm rebuild) instead of copying node_modules between images, and include the Node major in any node_modules cache key.

require is not defined in ES module scope

Symptom: A CommonJS dependency crashes at load time on 26. I reproduced it with yargs 16.2.0 and 17.7.2: their extensionless entry file sits in a “type”: “module” package, which Node 26 now loads as ESM.

Fix: npm ls yargs, then update to 16.2.2, 17.7.3 or 18. For other packages, look for extensionless entry files and report them upstream.

Node won’t start on a minimal Linux host

Symptom: The official Linux tarball fails to run on a slim host or custom image where 22 or 24 used to work.

Fix: Since Node 25 the Linux binaries link against libatomic: install the libatomic or libatomic1 package. The official Alpine images already add it.

One upstream fails only on 26

Symptom: fetch() calls to one proxy or API start failing or hanging after the upgrade, while everything else works.

Fix: Confirm with the ALPN check from step 4. If it’s HTTP/2, set allowH2: false on a global undici Agent (or VERCEL_FETCH_DISABLE_H2=1 on Vercel) and tell the upstream’s owner.

My rule for this migration

Pin exact versions where you deploy, change one layer at a time, and let CI fail on deprecations on 26 before production ever sees them. The LTS label didn’t change a line of code on 28 October; it changed which version your floating pins pick.

Conclusion

For most apps, Node.js 26 LTS is an easy upgrade at the code level: the breaks are narrow (four removals, one stream behavior change, one TypeScript flag), and a grep plus one CI run finds them. The real work is operational: Corepack and Yarn v1 gone, a new Debian base behind node:lts, a new native ABI and HTTP/2 inside fetch().

Work in the order this guide follows: inventory, upgrade with 26 next to your current line in CI, fix, then adopt Temporal where dates hurt. If you’re still on 20, start today. And put two dates in your calendar: 30 April 2027, when 22 reaches end of life, and October 2027, when 27 becomes the next LTS.

Verification stamp

Checked on 3 Nov 2026: the 26.13.0 and 26.0.0 release notes, nodejs/Release, the nodejs.org dist index, the v26.13.0 API docs and the Undici source it bundles, the official Docker image files, setup-node v7.1.0, nvm v0.40.8, fnm, mise and Volta docs, and the Vercel, Netlify and AWS Lambda docs. The JavaScript snippets, or equivalent test files for the stream fragments, ran on the official 26.13.0 win-x64 build (SHA-256 checked) and on 22.22.0. Not run: GitHub Actions, Docker builds, the codemods and anything on Linux.

Sources

Diego Rodriguez

Diego Rodriguez

Senior Full-Stack & AI Engineer

Diego has 10+ years of experience building production-grade AI-powered applications, from LLM orchestration and RAG pipelines to ML-driven risk detection and algorithmic trading systems.

Learn more about Diego →