Node.js v26 Upgrade: Temporal by Default, Type Stripping
Node.js 26 ships Temporal enabled by default and stable type stripping. Here is what actually breaks on upgrade and a script that tells you before you deploy.
On this page 9 sections
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.

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()andIterator.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 |
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:
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:
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:
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:
// 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/polyfillandtemporal-polyfillinstall a globalTemporal, 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.jsonis ignored at runtime, sopathsaliases and downlevelling don’t work.- TypeScript inside
node_modulesisn’t processed. - Imports need the
.tsextension.
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:
{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"rewriteRelativeImportExtensions": true
}
}erasableSyntaxOnlymakestscreject enums, namespaces and parameter properties, so CI catches them before production does.verbatimModuleSyntaxrequires thetypekeyword on type-only imports. Node needs it because it erases by syntax and can’t tell what’s a type:
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:
// 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.
npm rebuild
# or, cleanest:
rm -rf node_modules && npm ciDocker 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:
Readablenow 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
CryptoKeytonode:cryptoAPIs (DEP0203) andKeyObject.from()with a non-extractableCryptoKey(DEP0204) are deprecated. Convert toKeyObjectexplicitly. - localStorage: with the experimental web storage flags, it returns
undefinedwhen 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
binscripts in ESM packages
#!/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 reviewIt’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.
nvm install 26 && nvm use 26
rm -rf node_modules && npm ciDon’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.
NODE_OPTIONS="--trace-warnings --trace-deprecation" npm testLeave out --throw-deprecation for now. It will fail on DEP0205 from your tooling.
4. Add 26 to the CI matrix. Keep 24.
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.
Related reading
- Understanding the Event Loop in Node.js. The internals worth knowing before you profile anything.
- Running TypeScript in Node.js. Where type stripping fits in the toolchain.
- Node.js 24: The Game Changer Release. The LTS most teams are upgrading from.
- Node.js Memory Management. Useful when you’re watching RSS through a V8 bump.
✨ Thank you for reading and I hope you find it helpful. I sincerely request for your feedback in the comment’s section.