Node.js 26 shipped on May 5, 2026 and becomes LTS in October. The changelog looks short: a date API, a stability promotion, a V8 bump and a handful of removals.

The catch is a deprecation warning many teams will see and can’t fix, because their own code doesn’t trigger it.

I’ve run 26 locally since release day. This guide covers what changed, what breaks and a script that checks your app before you switch.

Node.js 26

Should you upgrade now?

Test now. Deploy after October.

Node 26 is a Current release until October, so it can still take breaking changes. LTS releases get only security and bug fixes. If you’re on Node 24 LTS, it’s supported through April 2028, so there’s no rush.

Upgrade early in two cases: you want Temporal in production, or you’re starting a new project that will ship after 26 reaches LTS.

Either way, run the scanner and add 26 to your CI matrix this month.

Node.js 26 at a glance

Released May 5, 2026 (Current)
LTS October 2026
V8 14.6.202.33 (Chromium 146)
Undici 8.0.2
ICU 78.3
NODE_MODULE_VERSION 147

New:

  • Temporal is enabled by default. No flag, no polyfill.
  • TypeScript type stripping is stable and on by default.
  • V8 14.6 adds Map.prototype.getOrInsert(), getOrInsertComputed() and Iterator.concat().

Breaking changes, ordered by how likely they are to hit you:

Change Symptom Fix
ABI bumped to 147 ERR_DLOPEN_FAILED rm -rf node_modules && npm ci
module.register() deprecated (DEP0205) Warning from your tooling Upgrade the tool
--experimental-transform-types removed Enums and decorators fail Stay on tsc/tsx
_stream_* modules removed MODULE_NOT_FOUND Update the old dependency
writeHeader() removed TypeError Use writeHead()
Extensionless CJS in ESM packages require is not defined Rename to .cjs

Temporal: the new date API

Date has been a problem since 1995. It’s mutable and parses ambiguously. Its idea of a timezone is “UTC or whatever this machine calls local”, and months start at zero. That’s why moment, date-fns, dayjs and luxon exist.

Temporal is the standards-track replacement. It isn’t one type but a family, and most of the work is picking the right one.

Pick the right type

Type Represents Use it for
Temporal.Instant An exact moment, no timezone Timestamps, logs, elapsed time
Temporal.ZonedDateTime A moment plus its timezone Meetings, schedules, anything DST-sensitive
Temporal.PlainDate A calendar date, no time or zone Birthdays, invoice dates, holidays
Temporal.Duration A length of time SLAs, timeouts, offsets
javascript
const now = Temporal.Now.instant();
const meeting = Temporal.ZonedDateTime.from('2026-08-18T09:00[America/New_York]');
const invoiceDate = Temporal.PlainDate.from('2026-08-18');
const sla = Temporal.Duration.from({ hours: 48 });

The off-by-one-day bug goes away

new Date('2026-08-18') parses as UTC midnight, so a user in Los Angeles sees August 17th. Most of us have shipped this bug. A PlainDate has no time or timezone, so nothing can shift.

Temporal objects are also immutable. Arithmetic returns a new object:

javascript
const due = invoiceDate.add({ days: 30 });   // PlainDate 2026-09-17
invoiceDate.toString();                       // '2026-08-18' - unchanged

DST arithmetic is correct by default

ZonedDateTime knows that “24 hours later” and “tomorrow at the same time” are different things:

javascript
const beforeDST = Temporal.ZonedDateTime.from(
  '2026-11-01T01:30:00-04:00[America/New_York]'
);

beforeDST.add({ hours: 24 }).toString();
// 2026-11-02T00:30:00-05:00 - 24 real hours, wall clock moved 23
beforeDST.add({ days: 1 }).toString();
// 2026-11-02T01:30:00-05:00 - same wall clock, 25 real hours

With Date, you’d need a library for that, or a bug report six months later.

Comparison and elapsed time:

javascript
const overdue = Temporal.PlainDate.compare(due, Temporal.Now.plainDateISO()) < 0;

const elapsed = Temporal.Now.instant().since(requestStart);
logger.info({ ms: elapsed.total({ unit: 'millisecond' }) }, 'request complete');

Convert at the database boundary

Your database driver doesn’t know about Temporal. The MongoDB driver serialises Date to BSON Date, and pg maps timestamptz to Date. Keep Temporal in your domain logic and convert at the edges:

javascript
// Temporal → driver
const createdAt = new Date(zdt.epochMilliseconds);

// driver → Temporal
const zdt = someMongoDate
  .toTemporalInstant()
  .toZonedDateTimeISO(Temporal.Now.timeZoneId());

Date.prototype.toTemporalInstant() ships with Temporal as the official bridge.

Don’t rip out date-fns yet

  • Library authors: your consumers may still run Node 22 or 24. Using Temporal sets a hard Node 26 floor.
  • Polyfill users: @js-temporal/polyfill and temporal-polyfill install a global Temporal, and Node 26 now has a native one. If the polyfill doesn’t guard against this, part of your code gets the native version and part gets the shim, with different prototypes. Remove the polyfill during the upgrade, not after.

Start with new code. Use Temporal in your next feature and let old code age out.

TypeScript type stripping

node server.ts now runs without a flag. Node replaces types with whitespace, so line numbers and stack traces stay accurate without source maps. --no-strip-types turns it off.

The breaking part: --experimental-transform-types has been removed, not deprecated. That flag handled TypeScript syntax that can’t simply be erased.

What works and what doesn’t

Construct Under type stripping
import type, interfaces, annotations, generics ✅ Erased cleanly
enum Foo { A, B } ❌ Not supported
namespace with runtime values ❌ Not supported (type-only is fine)
Parameter properties, constructor(private x: T) ❌ Not supported
Decorators, @Injectable() ❌ Parser error
.tsx files ❌ Not supported
Import aliases ❌ Not supported

Three more limits:

  • tsconfig.json is ignored at runtime, so paths aliases and downlevelling don’t work.
  • TypeScript inside node_modules isn’t processed.
  • Imports need the .ts extension.

NestJS, TypeORM and decorator-heavy code

These frameworks depend on decorators and parameter properties. Node’s stripper will never run that code, because decorators aren’t a JavaScript feature yet and there’s nothing to erase them into.

That’s a design boundary, not a bug. Keep using tsc or tsx. The built-in stripper is for code that treats TypeScript as annotated JavaScript.

A tsconfig that matches Node

If you want to run .ts files on Node directly, make the compiler enforce the same rules:

json
{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "rewriteRelativeImportExtensions": true
  }
}
  • erasableSyntaxOnly makes tsc reject enums, namespaces and parameter properties, so CI catches them before production does.
  • verbatimModuleSyntax requires the type keyword on type-only imports. Node needs it because it erases by syntax and can’t tell what’s a type:
typescript
import type { User } from './user.ts';                   // ✅ erased
import { createUser, type UserInput } from './user.ts';  // ✅
import { User } from './user.ts';                        // 💥 runtime error - no such export

Smaller additions: Map upsert

V8 14.6 adds getOrInsert() and getOrInsertComputed() to Map and WeakMap. They replace a pattern most of us have typed many times:

javascript
// before
let bucket = groups.get(key);
if (!bucket) {
  bucket = [];
  groups.set(key, bucket);
}
bucket.push(item);

// Node 26
groups.getOrInsertComputed(key, () => []).push(item);

Use getOrInsert for a cheap literal default. Use getOrInsertComputed when building the default costs something; the callback runs only on a miss.

Breaking changes in detail

1. Native addons must be rebuilt

NODE_MODULE_VERSION is now 147. A .node binary built for Node 24 fails with ERR_DLOPEN_FAILED.

bash
npm rebuild
# or, cleanest:
rm -rf node_modules && npm ci

Docker images with a cached node_modules layer get hit hardest, and so do CI caches keyed only on the lockfile hash. Add the Node major version to the cache key.

2. DEP0205: a warning you can’t fix yourself

module.register() is deprecated in favour of module.registerHooks(). The async off-thread loader hooks were too complex to fix, so the replacement runs synchronously in the main thread.

You probably don’t call module.register(). Your tools do. The warning has been reported against Storybook, Cypress, Tailwind, Corepack and @sentry/node, so a clean codebase can print:

(node:41233) [DEP0205] DeprecationWarning: module.register() is
deprecated. Use module.registerHooks() instead.

register() still works; it only warns. The problem is CI with --throw-deprecation, which fails your build on code you can’t patch. Upgrade the tool, or keep that flag away from your dev toolchain.

If you maintain a loader, this is real work. registerHooks() accepts only synchronous hooks, so an async load hook that awaited readFile() must switch to sync I/O.

3. Legacy _stream_* modules removed

_stream_wrap, _stream_readable, _stream_writable, _stream_duplex, _stream_transform and _stream_passthrough are gone. They were never public, but old socket and proxy libraries still import _stream_wrap. The failure is a MODULE_NOT_FOUND at require time, so you’ll see it immediately.

4. writeHeader() removed

http.Server.prototype.writeHeader() was deprecated for years. Use writeHead().

5. Extensionless CommonJS in ESM packages

Extensionless files in a "type": "module" package no longer load as CommonJS. The usual victim is a CJS bin script with no extension, which now parses as ESM and fails on the first require(). Rename it to .cjs and update bin in package.json.

6. Behaviour changes worth testing

These won’t throw errors, but they can change results:

  • Streams: Readable now reads one buffer at a time. If you expect one .read() call to drain everything, loop it. for await, pipeline() and .pipe() are unaffected.
  • fetch: now runs on Undici 8. Check code that relies on exact error shapes or header casing.
  • Crypto: passing a CryptoKey to node:crypto APIs (DEP0203) and KeyObject.from() with a non-extractable CryptoKey (DEP0204) are deprecated. Convert to KeyObject explicitly.
  • localStorage: with the experimental web storage flags, it returns undefined when no backing file is set.

7. Building from source

The GCC minimum is 13.2 and Python 3.9 is dropped. This mostly affects node-gyp on old build images; an Ubuntu 20.04 builder won’t work.

Pre-upgrade scanner

Save this as node26-check.mjs in your project root and run node node26-check.mjs. It has no dependencies and checks for:

  • the Node version you’re running on
  • native addons that need a rebuild
  • removed and deprecated APIs in your source
  • TypeScript syntax that type stripping can’t handle
  • extensionless bin scripts in ESM packages
javascript
#!/usr/bin/env node
// node26-check.mjs - run with: node node26-check.mjs
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
import { join, extname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next']);
const SRC = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts']);
const SELF = fileURLToPath(import.meta.url);
const findings = [];
const add = (level, code, msg, where) => findings.push({ level, code, msg, where });

function walk(dir, onFile) {
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    const full = join(dir, entry.name);
    if (entry.isDirectory() && !SKIP.has(entry.name)) walk(full, onFile);
    else if (entry.isFile()) onFile(full);
  }
}

// 1. runtime
const major = Number(process.versions.node.split('.')[0]);
console.log(`node ${process.versions.node} · ABI ${process.versions.modules}\n`);
if (major < 26) add('warn', 'RUNTIME', `Scanning on Node ${major}. Re-run on 26.`, '-');

// 2. native addons need a rebuild (ABI 147)
if (existsSync('node_modules')) {
  const native = new Set();
  const scan = (dir) => {
    for (const e of readdirSync(dir, { withFileTypes: true })) {
      const full = join(dir, e.name);
      if (e.isDirectory()) scan(full);
      else if (extname(e.name) === '.node') {
        const [, a, b] = full.split('/');
        native.add(a.startsWith('@') ? `${a}/${b}` : a);
      }
    }
  };
  scan('node_modules');
  if (native.size) add('warn', 'ABI-147', `Rebuild native addons: ${[...native].join(', ')}`, 'node_modules');
}

// 3. removed & deprecated APIs in source
const PATTERNS = [
  [/require\(\s*['"]_stream_\w+['"]\s*\)/, 'fail', 'REMOVED', 'Legacy _stream_* module removed'],
  [/\.writeHeader\s*\(/, 'fail', 'REMOVED', 'writeHeader() removed - use writeHead()'],
  [/\bmodule\.register\s*\(/, 'warn', 'DEP0205', 'module.register() deprecated'],
  [/experimental-transform-types/, 'fail', 'REMOVED', '--experimental-transform-types removed'],
  [/@js-temporal\/polyfill|temporal-polyfill/, 'warn', 'TEMPORAL', 'Temporal polyfill may collide with native global'],
];
const TS_PATTERNS = [
  [/^\s*(export\s+)?(const\s+)?enum\s+\w+/m, 'fail', 'TS-ENUM', 'enum is not erasable'],
  [/^\s*(export\s+)?namespace\s+\w+/m, 'warn', 'TS-NAMESPACE', 'namespace with runtime values is not erasable'],
  [/constructor\s*\([^)]*\b(private|public|protected|readonly)\b/, 'fail', 'TS-PARAMPROP', 'Parameter properties are not erasable'],
  [/^\s*@[A-Z]\w*\s*\(/m, 'fail', 'TS-DECORATOR', 'Decorators are a parser error'],
];

walk('.', (full) => {
  const ext = extname(full);
  if (resolve(full) === SELF) return;
  if (ext === '.tsx') return add('warn', 'TS-TSX', '.tsx is not supported', full);
  if (!SRC.has(ext) || statSync(full).size > 512 * 1024) return;
  const src = readFileSync(full, 'utf8');
  const rules = ext.includes('ts') ? [...PATTERNS, ...TS_PATTERNS] : PATTERNS;
  for (const [re, level, code, msg] of rules) if (re.test(src)) add(level, code, msg, full);
});

// 4. extensionless bin in an ESM package
if (existsSync('package.json')) {
  const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
  if (pkg.type === 'module' && pkg.bin) {
    const bins = typeof pkg.bin === 'string' ? [pkg.bin] : Object.values(pkg.bin);
    for (const b of bins) {
      if (!extname(b)) add('fail', 'EXTLESS-CJS', `bin "${b}" has no extension - now parsed as ESM`, 'package.json');
    }
  }
}

// report
findings.sort((a, b) => (a.level === 'fail' ? -1 : 1) - (b.level === 'fail' ? -1 : 1));
for (const f of findings) console.log(`${f.level.toUpperCase()}  [${f.code}] ${f.msg}\n      ${f.where}`);
const fails = findings.filter((f) => f.level === 'fail').length;
console.log(`\n${fails} blocking · ${findings.length - fails} to review`);
process.exit(fails ? 1 : 0);

Sample output:

node 26.0.0 · ABI 147

FAIL  [TS-ENUM] enum is not erasable
      src/status.ts
WARN  [ABI-147] Rebuild native addons: bcrypt
      node_modules

1 blocking · 1 to review

It’s a pattern match, not a type checker. A decorator-shaped comment gives a false positive, and it can’t see into your dependencies’ source or into fetch behaviour changes. A clean run means “no obvious blockers”. Your test suite covers the rest.

Upgrade checklist

1. Run the scanner on your current branch. Fix every FAIL line before switching runtimes.

2. Switch locally and reinstall from scratch.

bash
nvm install 26 && nvm use 26
rm -rf node_modules && npm ci

Don’t skip the clean install. A stale node_modules causes ABI failures that look like bugs in your code.

3. Run tests with warnings visible.

bash
NODE_OPTIONS="--trace-warnings --trace-deprecation" npm test

Leave out --throw-deprecation for now. It will fail on DEP0205 from your tooling.

4. Add 26 to the CI matrix. Keep 24.

yaml
strategy:
  matrix:
    node-version: [22, 24, 26]

Add the Node version to your dependency cache key while you’re there.

5. Deploy to staging, then a canary. Watch RSS and event-loop lag for a full traffic cycle before rolling out wider. A major V8 bump changes garbage collection behaviour, and that only shows up under real load.

✨ Thank you for reading and I hope you find it helpful. I sincerely request for your feedback in the comment’s section.