The wrong Node.js version in production costs you a hot Saturday. You upgrade in dev, ship to production, and discover that one of your dependencies pinned engines.node = "18.x" and now refuses to install. Or your serverless platform's runtime is on 20.x and your local is on 22.x and the bug only reproduces on the platform. The Node.js version 2026 picture is cleaner than it has been in years, but only if you understand which lines are LTS, which ones to skip, and how to lock the version across your dev, CI, Docker, and serverless surfaces. This is the practical picture from running Node across five SaaS apps.
The state of the LTS lines in 2026
Node.js LTS lines as of mid-2026:
- Node 20.x: in maintenance LTS until April 2026. About to retire.
- Node 22.x: active LTS, the current default.
- Node 24.x: active LTS as of October 2025, currently the latest LTS.
- Node 25.x: current (non-LTS), released April 2026, will become 26.x LTS.
If you are picking today, Node 22.x is the safe production choice, with Node 24.x as the modern default for new projects. Skip Node 25.x in production. Migrate off Node 20.x if you have not already.
Why Node 22 vs 24
The difference between 22 and 24 is real but small for most teams.
Node 22 ships:
- Stable native fetch API (since 21).
- Stable test runner (since 20, fully stable in 22).
- V8 with current TC39 features.
- Node Permission Model, experimental but usable.
Node 24 ships:
- npm 11 with workspaces improvements.
- More mature ESM/CJS interop after years of churn.
- Performance gains in module loading and HTTP.
- The Permission Model promoted to stable.
For new projects in 2026, we default to Node 24 because the ESM story is finally clean and the dev-loop is faster. For existing projects already on Node 22 with no specific reason to upgrade, staying put is fine.
We do not run Node 25 in production. The current line shifts under your feet, and a SaaS in production wants a stable runtime, not a leading-edge feature pipeline.
What changed between Node 18 and Node 22 that matters
If you are still on Node 18 (in maintenance until April 2025, off-support since), here is what catches teams in the upgrade.
Native fetch is global
You can now use fetch, Request, Response, and FormData without importing anything. The node-fetch package can usually be removed. This affects your dependencies and your TypeScript types.
// Before, with node-fetch
import fetch from "node-fetch";
const res = await fetch("https://api.example.com");
// On Node 22+, no import
const res = await fetch("https://api.example.com");
The catch: error handling is different from axios or some node-fetch versions. fetch does not throw on 4xx/5xx. You check res.ok yourself.
Built-in test runner
node --test runs *.test.js and *.test.ts files (with the right loader). It is fast, native, and zero-dependency. We use it for small CLIs and tools. We still use Vitest or Jest on Next.js apps because the integration is better there.
ESM is the default in many tools
type: "module" in package.json is increasingly the norm. CommonJS is supported, but new tools assume ESM.
If your codebase mixes ESM and CommonJS, every upgrade is a chance to clean up. Pure ESM is now realistic. Pure CommonJS is increasingly painful. Mixing both is the worst of both.
Where to lock the version
Five places. Lock at all five or you will discover the mismatch in production.
1. package.json engines field
{
"engines": {
"node": ">=22.11.0 <23 || >=24.0.0"
}
}
This expresses "Node 22 LTS or Node 24 LTS, no others". Some platforms (Vercel, certain hosts) read this. Others ignore it. Set it anyway as documentation.
2. .nvmrc or .node-version
A single line file at the repo root:
24.7.0
Every contributor's nvm use (or fnm or volta) picks this up. CI tools like GitHub Actions read it natively.
3. CI workflow
If you use GitHub Actions:
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
node-version-file reads .nvmrc. One source of truth.
4. Dockerfile
Pin to a specific minor:
FROM node:24-alpine
We pin to the major in the base image and rebuild regularly to pick up patch updates. Pinning to a specific patch (node:24.7.0-alpine) is more reproducible but requires regular updates. We trade reproducibility for security patches.
The full pattern, including the standalone trace for Next.js, is in our Next.js 15 standalone Docker writeup.
5. Editor (VS Code, JetBrains)
Most editors pick up the version from .nvmrc automatically if you have a Node version manager installed. Verify by running node --version inside the integrated terminal. Mismatches between the editor's bundled Node and your runtime cause confusing autocomplete and type errors.
The npm vs pnpm vs yarn question
A related but separate decision. Which package manager do you run on which Node.
In 2026, the practical state:
- npm ships with Node, is fine, has caught up on speed.
- pnpm is faster, better at monorepos, slightly stricter about phantom dependencies.
- yarn has split into yarn classic (deprecated) and yarn berry (powerful but opinionated).
We use pnpm on the lesson-planning monorepo because of workspace support. We use npm on Carriva because the project is single-package and the npm story is simpler. We do not use yarn anywhere new.
Lock the package manager too:
{
"packageManager": "[email protected]"
}
With Corepack enabled (Node 16+), this auto-installs the right version. CI and contributors get the same package manager without "did you have npm 9 or 10 installed".
Native modules and Alpine vs slim
A practical issue. Some npm packages have native components (sharp, bcrypt, prisma, sqlite3). On Alpine images (musl libc), these need different binaries than on glibc-based images.
Symptoms of mismatch:
- "Cannot find module" at runtime for a binary that exists.
- "could not find an Engine for this platform" from Prisma.
- Crashes on first import of
sharp.
The fix depends on the package. For Prisma, add linux-musl to the schema's binaryTargets and install openssl in the runner stage. For sharp, install the prebuilt binary or fall back to slim Debian. We covered the Prisma + Alpine specifics in our memory; the cliff notes are: binaryTargets = ["native", "linux-musl"] in the schema and RUN apk add --no-cache openssl in the Docker runner stage.
If you do not need the smaller image size, node:24-slim (Debian-based) avoids most of these issues at the cost of a 100 MB larger image.
Long-running process patterns
Production Node.js is more than just a Next.js server. We run:
- HTTP API servers (Next.js, sometimes Express).
- Worker processes that consume from a queue and run background jobs.
- Cron-like scheduled tasks, often via systemd timers or platform schedulers.
Each has its own patterns:
HTTP servers
Use node --enable-source-maps in production for readable error stacks. Run behind a reverse proxy that handles TLS. Set memory limits explicitly:
node --max-old-space-size=2048 server.js
A 2 GB heap is usually plenty for a Next.js standalone server. Higher means you have a memory leak you should investigate, not paper over.
Workers
Workers should be killable. process.on("SIGTERM", ...) for graceful shutdown. Avoid global state that does not serialize. Long-running workers benefit from periodic restarts (every few hours or N jobs).
We covered the broader self-hosted infrastructure story in our solo dev DevOps homelab writeup; Node workers are part of that picture.
Cron tasks
A cron task that can run for 30+ minutes (database backup, batch import) should checkpoint progress. If the task is killed mid-run, the next run resumes instead of restarting. We learned this discipline from one bad night where a 4-hour batch lost its state on a SIGTERM.
What about Bun and Deno
Reasonable question in 2026.
Bun is fast and has a tight dev loop. We have used it for scripting tasks. We have not put it in production yet because the long-tail compatibility (especially around Node.js native APIs and some npm packages with C++ addons) is not yet 100%. We will reevaluate in 12 months.
Deno has a different stance (URL-based imports, secure-by-default permissions). For greenfield CLI tools and scripts, it is a serious contender. For replacing Node.js in a production SaaS, the operational story is still smaller and the talent pool is smaller.
For 2026 production SaaS, Node.js is the boring choice and the right choice. Bun and Deno are watch-list items.
The right Node.js version is the boring one. The latest LTS, locked at every layer, with one clean upgrade path planned for next quarter.
Migration playbook from Node 18 or 20 to 22 or 24
A safe migration:
- Pin to the new version locally via nvm. Run dev, run tests, run integration tests.
- Update
package.jsonengines and.nvmrc. - Update CI to use the new
.nvmrc. - Update Docker base image to
node:22-alpineornode:24-alpine. - Run a full build and test in CI before deploying.
- Deploy to staging. Watch logs. Watch memory. Watch error rates.
- Deploy to production. Have a rollback plan: previous Docker image is one click away.
We have done this migration twice (16→18, 18→22). Both times the pain was in step 4 because Alpine and native modules conspired against us. Plan for an extra hour of debugging there.
Connecting to your runtime stack
Your Node.js version interacts with your hosting platform. Coolify, Dokku, CapRover, and serverless platforms each have their own Node.js story. We compared the self-hosted PaaS options in our Coolify vs Dokku vs CapRover piece. All three respect the version you specify in your Dockerfile or .nvmrc, so the locking discipline carries through.
For database connectivity (Postgres in our case), the Node driver matters too. We use pg natively. The driver works fine across Node 18 to 24. Some teams use postgres.js for performance; both are stable on current Node. The broader Postgres self-hosting picture is in our self-hosting Postgres writeup.
Common mistakes
A few:
- Pinning to too-specific a patch in production.
24.7.0exact pinning means you miss security patches. Major + minor is enough. - Forgetting to update the Docker base on a Node upgrade. Local says 22, container says 18. Bugs only reproduce in production. Always update both.
- Mixing package managers. A repo with both
package-lock.jsonandpnpm-lock.yamlis asking for trouble. Pick one. - Ignoring
engineswarnings. A dependency that says it needs Node 22+ probably means it.
What we would test first
If you are reviewing your Node.js setup today:
- Check your dev, CI, and Docker versions. Are they all the same? If not, that is where to start.
- Check
.nvmrcandpackage.jsonengines. Both set? Both consistent? - Are you on Node 22 or 24? If older, plan the upgrade now.
- Pin the package manager via
packageManagerinpackage.jsonand Corepack. - Test a small change end-to-end. The first non-trivial bug after a Node upgrade is the canary.
TL;DR
Run Node 22 or 24 in production. Lock the version in package.json engines, .nvmrc, CI, and your Dockerfile. Use Corepack to pin the package manager. Skip the current line in production. Plan a quarterly upgrade discipline rather than rare big-bang migrations. Node.js version 2026 is finally a quiet decision if you treat it as one.



