Intro
Upgrading Node.js is a recurring operational task that impacts build tooling, application behavior, performance, and security posture. Done well, it shrinks risk and unlocks modern language features and ecosystem updates. This guide gives you a practical, low-drama way to plan and execute Node.js upgrades with commands you can run, expected results, diagnostics, and clear rollback paths. The examples assume a typical web API stack (Express server, MongoDB driver, optional Redis client), but the method fits most Node.js services.
What you will get:
- A baseline inventory approach that catches surprises early
- A safe configuration path with version pinning and small blast radius
- A step-by-step upgrade with verification and diagnostics
- Recovery playbooks for common breakages
- A reusable operations checklist for future upgrades
Prerequisites:
- Shell access to your development and test environments
- Permission to install or select Node.js versions (nvm on macOS/Linux; an equivalent version manager or installer on Windows)
- A working test suite or at least a health endpoint to probe
- Basic Git branching and the ability to restore a lockfile
Version and Environment Inventory
Establishing a precise baseline is the most cost-effective way to reduce upgrade risk. Capture the following:
Runtime and tooling
- Record Node.js and npm versions:
node -v
npm -v
- Identify the package manager used (npm, yarn, pnpm). Use only one manager during the upgrade.
- Confirm OS details (e.g., Linux distribution, glibc version) if you use native modules.
Application and dependencies
- Note the current branch and create a dedicated upgrade branch, for example:
upgrade/node20 - Snapshot
package.jsonandpackage-lock.json(oryarn.lock/pnpm-lock.yaml) - List top-level dependencies and their versions:
npm ls --depth=0
- Identify native add-ons (modules that compile):
- Look for dependencies with install scripts running
node-gyporprebuild - Search for
binding.gypfiles
Environment and topology
- Record environment variables used by the service, especially
NODE_ENVand any TLS-related variables - Record external services: MongoDB URI, Redis host/port, upstream APIs used by your app
- Identify one narrow, low-risk pilot path (a single service, a single endpoint, or a single job) to trial the upgrade
Reference table for selecting a scope and anticipating runtime shifts:
| Target Node | Key changes to consider | Risk notes |
|---|---|---|
| 16 -> 18 | fetch available, OpenSSL updates, V8 bumps | Native modules rebuild, TLS defaults shift |
| 18 -> 20 | Test runner availability, permission flags (experimental), URL/stream refinements | ESM/CJS edge cases, stricter warnings |
| 20 -> 22 | Performance and V8 updates, runtime deprecations advance | Removed legacy APIs; audit deprecations |
Safe Configuration Path
Your goal is a controlled, observable change with easy rollback. Apply these choices before touching code:
Choose the target version deliberately
- Prefer the current active LTS for production apps
- If you are on an older LTS, plan a single-step jump where possible (for example, 16 -> 20) rather than hopping through every interim version, unless you hit blockers that force an intermediate step
Isolate the blast radius
- Use a feature flag or route-gating method to test a single endpoint or background job first
- Run the new Node.js version side-by-side in development and test, only promoting when checks are green
Pin and record versions
- Add or update
.nvmrcwith your target major version (for example,20) - In
package.json, set anenginesfield to communicate runtime expectations:
{
"engines": {
"node": ">=20 <21"
},
"engineStrict": false
}
Set engineStrict only if your package manager and org policy require strict enforcement.
Protect yourself with a clean, reversible starting point
- Commit the current lockfile and
node_modulesremoval as a single change only when switching between Node.js majors - Keep the upgrade in a dedicated branch to avoid mixing unrelated changes
Practical Upgrade Walkthrough
This example assumes macOS/Linux with nvm. On Windows, use a comparable tool (for example, nvs or a trusted installer) and similar commands.
Create the branch and confirm the baseline
git checkout -b upgrade/node20
node -v
npm -v
npm ci
npm test
Expected result: All tests green, app starts without new warnings.
Install and select the target Node.js
nvm install 20 --latest-npm
nvm use 20
node -v
npm -v
echo "20" > .nvmrc
Expected result: The shell uses the new Node 20; .nvmrc communicates your intent to other developers.
Refresh dependencies and lockfile under the new runtime
rm -rf node_modules
rm -f package-lock.json
npm install
npm ls --depth=0
Expected result: A fresh lockfile reflects transitive dependency updates compiled against Node 20, including any native modules.
Rebuild or verify native add-ons (if any)
npm rebuild
On systems requiring build tools, ensure prerequisites are present (for example, Python and a C/C++ toolchain) if native modules exist.
Expected result: No build failures from node-gyp or prebuild; compiled artifacts present in node_modules.
Address ESM/CJS and standard library differences
If you use ESM ("type": "module\)), prefer native ESM imports:
// Before (CommonJS)
const fs = require('fs/promises');
// After (ESM)
import fs from 'node:fs/promises';
If a dependency is ESM-only and you use CommonJS, you may see ERR_REQUIRE_ESM. Options:
- Convert your file to ESM (rename to
.mjsor set "type": "module\)) and useimport - Load the module via dynamic
import()in CommonJS contexts
Start the service with diagnostic flags to catch issues early
NODE_OPTIONS="--trace-warnings --trace-deprecation" npm start
Expected result: The app starts; any deprecated APIs are logged with stack traces to guide fixes.
Example: Express API with MongoDB and Redis
// server.js (Express)
import express from 'express';
import { MongoClient } from 'mongodb';
import { createClient as createRedisClient } from 'redis';
const app = express();
const mongo = new MongoClient(process.env.MONGO_URI);
const redis = createRedisClient({ url: process.env.REDIS_URL });
app.get('/health', async (req, res) => {
try {
await mongo.db('admin').command({ ping: 1 });
await redis.ping();
res.json({ ok: true, node: process.version });
} catch (err) {
res.status(500).json({ ok: false, error: String(err) });
}
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`listening on ${port}`));
Start the app and probe health:
npm start
curl -sf http://localhost:3000/health | jq
Expected result: JSON shows ok: true and node: v20.x. If connections fail, your service logs should provide stack traces; confirm URIs and credentials in environment variables.
Verification and Diagnostics
Verification confirms that the new runtime behaves as expected across code paths you care about. Use explicit checks with observable output.
Recommended checks and expected outcomes:
| Check | Command | Expected result |
|---|---|---|
| Runtime version | node -v | Prints v20.x (target) |
| Package manager health | npm -v && npm doctor | Recent npm version; doctor reports no critical issues |
| Clean install | npm ci (pre-upgrade), npm install (post-upgrade) | Deterministic dependency tree; no audit/severity spikes |
| Rebuild native | npm rebuild | No build errors; native deps present |
| Unit/integration tests | npm test | All tests green; no new flaky tests |
| Service boot | NODE_OPTIONS="--trace-warnings --trace-deprecation" npm start | Starts cleanly; warnings actionable |
| Health endpoint | curl -sf http://localhost:3000/health | HTTP 200; JSON ok: true; node reports target version |
Additional diagnostics to consider:
- Enable verbose logging of your MongoDB and Redis clients temporarily to spot subtle timeouts or authentication changes
- Use
process.report(if available) to generate diagnostic reports when encountering native crashes - Run a small load test locally (for example, hitting a single endpoint at a steady rate) to catch event loop stalls or backpressure issues
Failure Modes and Recovery
Common issues you may encounter after a Node.js major upgrade and how to resolve or roll back safely:
Native module build failures
- Symptom:
node-gyperrors, missing compiler toolchain, or incompatible prebuilt binaries - Fix: Install necessary build tools; upgrade the offending dependency to a version supporting your target Node.js; as a stopgap, pin to a compatible version
- Rollback: Switch to prior Node.js via version manager and restore prior lockfile
TLS/crypto mismatches
- Symptom: TLS handshake failures when connecting to services; errors mentioning OpenSSL
- Fix: Update client library to versions tested with your target Node.js; verify cipher suites and minimum TLS versions expected by servers
- Rollback: Revert runtime and lockfile; confirm that previous connections work
ESM/CJS boundary errors (ERR_REQUIRE_ESM or ERR_MODULE_NOT_FOUND)
- Symptom: Cannot load ESM-only packages from CommonJS modules
- Fix: Convert entry points to ESM, or use dynamic
import()where needed; ensure "type": "module" is set appropriately - Rollback: Revert changes and runtime; plan a targeted refactor
Deprecation becoming errors
- Symptom: Previously working deprecated APIs now throw or behave differently
- Fix: Replace deprecated APIs; inspect
--trace-deprecationoutput for stack locations - Rollback: Pin to prior runtime while implementing replacements
Performance regressions under specific workloads
- Symptom: Higher latency or CPU after upgrade
- Fix: Capture CPU profiles and GC logs; check hot paths for sync I/O or tight loops; validate that dependency updates did not change defaults (e.g., pooling)
- Rollback: Revert while you isolate the regression; try updating only the runtime or only specific libraries to bisect the cause
Recovery procedure
# Stop the upgraded instance(s)
nvm use 18 # Or your equivalent version switch
git checkout HEAD -- package-lock.json
rm -rf node_modules
npm ci
npm start
Verification after rollback:
node -vprints the prior version- Tests and health checks pass as before
- Logs do not show new warnings introduced by the failed attempt
Operations Checklist
Use this checklist to repeat the process across services with minimal rework:
Planning and inventory
- Identify the target LTS and the smallest valuable pilot (one service or endpoint)
- Record current Node.js and npm versions; confirm a single package manager
- Snapshot
package.jsonand lockfile; list top-level dependencies - Note native modules and required build tools
Environment preparation
- Create upgrade branch
- Install target Node.js via a version manager; update
.nvmrc - Pin
enginesinpackage.jsonif your policy requires it
Execution
npm cion the old runtime to confirm a clean baseline- Switch to the new runtime; remove
node_modulesand lockfile; runnpm install npm rebuildfor native modules- Start the app with
NODE_OPTIONS="--trace-warnings --trace-deprecation" - Adjust ESM/CJS import/require usage where needed
Verification
- Run tests (unit, integration)
- Hit health endpoints and inspect logs
- Optionally run a light load test on a single endpoint
Decision and rollout
- If green: merge and schedule a controlled promotion to higher environments
- If issues: fix forward if small and well-understood; otherwise rollback cleanly and document the blocker
Rollback
- Switch back to the previous Node.js version
- Restore the last known-good lockfile;
npm ci - Re-run health and tests to confirm recovery
Conclusion
You have a practical way to upgrade Node.js: establish a precise baseline, isolate a small but representative pilot, move deliberately with version pinning and clean lockfiles, verify with observable checks, and keep rollback simple and fast. The same method scales to a portfolio of services: repeat the checklist, capture lessons per service (native modules, ESM boundaries, TLS defaults), and standardize on a target LTS for consistency. When in doubt, keep the pilot narrow, make outcomes measurable, and only expand the blast radius when the evidence shows the upgrade is safe.