August 22, 2026

Node 26: What Breaks When You Upgrade, and When the Jump Is Worth It (LTS Guide)

Photo of Marco Orta Marco Orta | 12 min read
Compartir
Illustration of a Node.js hexagon passing through a version gate with old gears falling to one side
Table of Contents

    Node 26 shipped on May 5, 2026 and enters LTS in October. It’s a calm upgrade — the list of things that actually break is short, and almost all of it has been warned about for years — but it has one property no previous version had: it’s the last release of the two-per-year model. Starting with Node 27, the project moves to one release per year, and every release is LTS. The odd/even distinction that spent a decade deciding which version you could run in production disappears.

    That makes this upgrade more than a version bump: it’s the last train on the old schedule, and it’s worth boarding knowing what’s inside. Here’s what really breaks, sorted by impact, and at the end the full calendar so you can decide when to move.

    First: where are you coming from?

    The effort depends on your starting point:

    • From Node 24 (the current LTS): the comfortable scenario. The incompatible changes are few and very localized; for most projects the migration is checklist work, not refactoring work.
    • From Node 22: still reasonable, but with more fronts — you absorb everything that changed in 24 (V8, permissions, minimum glibc) plus what’s new in 26. Node 22 leaves maintenance in April 2027, so you have a real deadline.
    • From Node 25: odd releases were never for production, and this was the last one that will ever exist. Jump to 26 without thinking twice.

    The schedule change: Node 27 debuts a new model

    The official project announcement sums it up:

    AspectThrough Node 26From Node 27 on
    Major releases per year21
    LTS versionsEven-numbered onlyAll of them
    Short-lived odd versionsYesGone
    Alpha channelDidn’t exist6 months, allows semver-major changes
    Total support per version36 months36 months (unchanged)

    The concrete dates that affect you:

    • Node 26: LTS in October 2026, end of life in April 2029.
    • Node 27: alpha channel opens October 2026, release in April 2027, and it will also be LTS.
    • Node 24: still in LTS; end of life in April 2028.

    The practical consequence: you’ll no longer have to memorize which versions “count”. Every release from here on is production-grade, and one release per year means fewer migrations per decade for your team. If you maintain libraries, the part that concerns you is the alpha channel: six months to test breaking changes before each release ships.

    High impact

    1. The internal _stream_* modules are gone

    The removal with the most shrapnel, because it doesn’t break your code: it breaks old dependencies. The internal modules _stream_readable, _stream_writable, _stream_duplex, _stream_transform, _stream_passthrough and _stream_wrap — deprecated since Node 12 — have been removed entirely. Any require('_stream_readable') now throws MODULE_NOT_FOUND.

    No package maintained in recent years does that, but ecosystems drag fossils along. To find out whether you have one:

    grep -rn "_stream_" node_modules --include="*.js" -l | head
    

    If something shows up, you have two exits: update that package to a version using the public API (node:stream), or — if it’s abandoned — replace it. The readable-stream polyfill covers most cases where the package is yours and you just need a mechanical migration.

    2. Undici 8: the global fetch gets strict

    Node 26 ships Undici 8.0, the implementation behind the global fetch. It brings stricter header validation and changes to redirect handling. If your code builds requests with dynamically generated headers — from a database, from user input, from another API — what used to pass silently can now throw a TypeError.

    The pattern that shows up most in practice:

    // If any value carries \n or \r (e.g. a token copied with a trailing newline),
    // Undici 8 rejects it instead of silently sanitizing.
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${token}` },
    });
    

    The right fix is not catching the error: it’s sanitizing the value at the edge, where it enters your system (token.trim() when you read it). Having the request blow up before it leaves is the good behavior — with Undici 7 that token with a \n would travel and fail at the server on the other side, which is far harder to debug.

    3. Native addons: mandatory recompilation

    NODE_MODULE_VERSION moves to 147, so every compiled addon (bcrypt, sharp, better-sqlite3, canvas…) needs to be rebuilt or ship prebuilds for the new ABI. The error is unmistakable:

    Error: The module was compiled against a different Node.js version using NODE_MODULE_VERSION 137.
    

    npm rebuild solves it locally; in CI and Docker, rebuilding the image on the new base is enough. The detail that can actually stall you: the build requirements went up — GCC 13.2 minimum, and Python 3.9 support was dropped from the build toolchain. If you compile addons inside an old Debian/Alpine image, update the image before fighting the wrong error.

    Medium impact

    4. module.register() is runtime-deprecated

    The async module-hooks API — the one used by instrumentation loaders, on-the-fly transpilation and APM agents — now emits a runtime deprecation. The designated replacement is the synchronous module.registerHooks() API.

    This reaches you mostly through third parties: observability agents (Datadog, New Relic, OpenTelemetry) and tools like tsx live in that layer. The action item isn’t rewriting anything of yours: it’s updating those dependencies to the versions that already migrated before moving production to 26.

    5. writeHeader() removed from the HTTP server

    http.Server.prototype.writeHeader() was an undocumented alias of writeHead(). It’s gone. If it appears in your code, the fix is literal, letter for letter:

    // Before
    res.writeHeader(200, { 'Content-Type': 'application/json' });
    // Now
    res.writeHead(200, { 'Content-Type': 'application/json' });
    

    A grep -rn "writeHeader" src/ tells you in seconds whether you’re affected. Frameworks (Express, Fastify, Nest) have always used the documented API; this bites hand-written HTTP code from many years ago.

    6. type: "module" packages: the extensionless-CJS exception is gone

    In packages declared as ESM, loading a CommonJS file without an extension relied on a historical resolver exception that Node 26 removes. If a "type": "module" package of yours had a require of a path without an explicit .cjs/.js, it now fails. The fix is adding the extension — which is what ESM resolution always asked for.

    Low impact

    • localStorage with no backing file returns undefined instead of throwing. If you probed availability with try/catch around localStorage, invert the check: it’s a plain if now.
    • QuotaExceededError becomes a DOMException-derived interface. Only affects code comparing the error by constructor or numeric code.
    • Several crypto APIs advance in the deprecation cycle — one reaches end of life and others start warning at runtime. If you see no warnings with --pending-deprecation today, you’re not affected.
    • assert accepts printf-style messages in assertion errors. It’s an addition, but it changes text output: if your CI parses assert messages (bad idea, while you’re here), review it.

    What does NOT break, even if you’ve read otherwise

    • The Temporal API doesn’t break Date. Temporal arrives enabled by default in Node 26, but it’s an addition: all your Date code keeps working exactly as (badly as) before. Migrating is optional and gradual — I have a full guide to Temporal in Node 26 with the conversion table from date-fns and dayjs.
    • Running TypeScript directly is now stable. node app.ts works with no flags: type stripping left experimental status. The only thing that “breaks” is the --experimental-transform-types flag, which was removed precisely because the feature graduated — if you had it in an npm script, delete it and move on.
    • V8 14.6 only adds. Map.prototype.getOrInsert(), Iterator.concat() and the rest of the Chromium 146 features are additions with zero migration cost.

    The upgrade, in practice

    The order that produces the fewest surprises:

    1. Install 26 next to your current version, not over it. With fnm or Volta it’s one command, and rolling back takes seconds.
    2. npm rebuild (or delete node_modules and reinstall) to regenerate native addons against ABI 147.
    3. Run the suite with deprecations visible:
      node --pending-deprecation --trace-deprecation ./node_modules/.bin/vitest run
      
      The module.register() and crypto ones show up here, with a stack trace to the culprit.
    4. grep for the fossils: _stream_, writeHeader(. Two minutes that save you a production surprise.
    5. Update your agents (APM, telemetry, loaders) to versions that declare Node 26 support — it’s the layer that takes longest.
    6. Review the engines field in your package.json and your critical dependencies: npm ls --depth=0 plus a glance at EBADENGINE warnings during install.

    When should you upgrade?

    New project: start on 26 today. There’s no reason to begin something that will live for years on 24.

    Production on 24: the sensible window is October 2026, when 26 enters LTS — with 24 supported until April 2028 there’s no rush, but no reason to wait until the end either: the longer you wait, the further this list drifts from fresh memory.

    Production on 22: move now. April 2027 looks far away until it doesn’t, and the 22 → 26 jump is bigger than 24 → 26.

    Further reading:

    Frequently asked questions

    Compartir

    Search

    Tags

    Tutorial AI PHP Laravel JavaScript Web Development Best Practices Migration Laravel 13 SEO Claude Tools Security OpenAI MCP