To update Node.js, install a version manager (nvm, fnm, or Volta), use it to install the version you want, set it as the default for your shell, and commit a .nvmrc file so your project, your CI, and your deploy target pick the same version. That sentence sounds simple. The reason most engineers end up in a three-hour thread on GitHub is that one of those four pieces is missing, and Node has a habit of silently picking up a different major than the one the rest of the team is on.
This is the post I wish I had the first time I tried to update Node on a project that ran fine in dev and broke the moment it hit the deploy. The pattern is consistent: the local install is wrong, or the project has no pinned version, or the deploy is running an old runtime, or the native modules rebuild against a different Node major. We will cover each of those.
The interesting thing about “update Node version” is that the actual answer is mostly about which Node, not how to install it. Once the team agrees on the version, the rest of the work is mechanical.
Table of contents
- The direct answer
- Pick a version manager first, then a version
- The three managers that actually matter in 2026
- .nvmrc: the one file that prevents 90% of “works on my machine”
- The deploy side: what changes when Node moves
- Native modules and the rebuild you forgot about
- The CI step that is easy to skip
- The opinion this post is built on
- FAQ
The direct answer
To update Node on a local machine:
# with nvm
nvm install 22
nvm alias default 22
nvm use 22
# verify
node -v
npm -v
To make the project pick up the same version everywhere, commit a .nvmrc file with the major:
22
Then add a package.json engines field so npm yells when the wrong Node is running:
"engines": {
"node": ">=22 <23"
}
That is the practical loop. The rest of this post is the reasoning behind it, the alternatives, and the deploy-side gotchas.
Pick a version manager first, then a version
The single biggest mistake is installing Node twice. You download the official installer for Node 22, then brew install node puts a second Node 18 in /usr/local/bin, and a few weeks later you have three of them. The node your shell sees is whichever one wins PATH, and that is almost never the one you think.
A version manager collapses that problem into one directory per Node version, with one node symlink in your shell that points to whichever version the project asks for. Switching versions is one command. The cost is that you have to choose one.
I will not pretend the choice between managers is trivial. It is not. But the wrong answer is “no manager” — that path ends in nvm install 22 later, or a fresh machine that the new hire spends a day on. The right answer is the manager that the rest of your team already uses, so the .nvmrc is enough to make everyone’s node -v line up.
For a personal machine, pick whichever is fastest to install and learn. For a team, pick whichever has the best CI image support and the most boring failure modes. Boring is good here.
The three managers that actually matter in 2026
I have used all three in production. Here is the honest tradeoff.
nvm is the one every Stack Overflow answer assumes. It is a Bash script, it lives in ~/.nvm, and it works on macOS and Linux. The win is that almost every CI runner has it. The pain is that it is a shell function, not a binary, so it does not work in fish, it does not work in scripts that do not source it, and every few years somebody breaks it with a Bash update. For most teams it is the right default.
fnm is the Rust rewrite. It is fast, it works in fish, it works in scripts, and it does not require sourcing. The win is that the same binary works the same way in every shell and in CI. The pain is that fewer CI runners ship it preinstalled, so you either commit a setup step or vendor it.
Volta is the version-manager-plus-package-manager combo. It pins the Node version, the npm version, and the package manager version all in package.json. The win is that “works on my machine” stops being a problem because the manager pins the tools, not just the runtime. The pain is that it adds a tool that has to be installed on every machine and every CI.
For most teams the answer is nvm for local dev and CI plus a .nvmrc committed to the repo. It is the lowest-friction option that still pins the version. If your team is starting fresh and willing to do the setup, fnm is the more correct choice. If you have a team that keeps breaking builds because they are on different npm versions, Volta is the only one that solves that automatically.
There is also n (tj/n), which is the minimalist option. It is fine for a personal machine, less fine for a team, and I do not recommend it for a project you intend to hand to other people.
.nvmrc: the one file that prevents 90% of “works on my machine”
The .nvmrc file is one line:
22
That line is the version of Node the project expects. It is read by nvm (nvm use), by fnm (fnm use), by most CI systems (GitHub Actions has a built-in actions/setup-node step that honors it), and by anything else that bothers to check. It is the single most important file in the repo for keeping Node versions aligned.
The mistake is to commit .nvmrc and then not enforce it. A .nvmrc is a hint, not a rule. The actual rule is the engines field in package.json:
"engines": {
"node": ">=22 <23"
}
With engines set, npm will print a warning by default and refuse to install (with engine-strict=true in .npmrc) when the local Node is wrong. That turns a silent mismatch into a loud one. Most teams that think they have a Node version problem but cannot reproduce it locally are running into the missing engines field.
A useful exercise: open the project, run nvm use in the project root, then run node -v. If the output is not the same as what CI says, the deploy is the version that wins, and the deploy is what users hit. The local version does not matter past the point where it matches the deploy.
The deploy side: what changes when Node moves
The local install is the easy part. The deploy is where Node updates bite. There are three things that can change.
The runtime version on the platform. Most PaaS platforms have a default Node version that the team is not actively choosing. If the platform updates Node, the deploy updates with it. If the platform is on Node 20 and the project requires Node 22, the build fails at install or fails at start. The fix is to set the Node version in the platform config — most PaaS platforms read .nvmrc or accept a NODE_VERSION environment variable. If yours does not, that is a sign you are on the wrong platform.
The base image for Docker. If the Dockerfile says FROM node:20, the deploy runs Node 20 no matter what .nvmrc says. Updating Node means updating the base image. The right pattern is to use a slim base image that matches the .nvmrc major, and to set the working directory’s Node version explicitly. A Dockerfile that does not pin a base image is a future outage.
The build command. Some teams cache node_modules between builds, and the cache is keyed to the Node major. Bumping Node 20 to Node 22 means the cache is invalid; the next build takes ten minutes instead of two. The fix is to clear the build cache at the same time the runtime changes, or to be willing to pay the cache miss.
The pattern across all three is the same: declare the Node version in the repo, declare it again in the deploy config, and pay the cache miss once rather than paying weird build failures forever. If the deploy platform does not let you set the Node version, that is a stronger signal than the version number itself — it means the platform is making choices on your behalf, and you will not know about them until the day those choices break your build.
The deploy side is also where a hosting platform with explicit Node version control earns its keep. A platform that reads .nvmrc, exposes the runtime version in the dashboard, and rebuilds the container from a pinned base image removes the most common version-mismatch failure modes without the team having to think about them.
Native modules and the rebuild you forgot about
The first time a Node major changes, node-gyp rebuilds the native modules — bcrypt, sharp, canvas, better-sqlite3, anything that compiles a C++ addon. On a local machine that takes a few seconds per module. On a build server that takes minutes per module and is the single biggest reason a Node upgrade looks like a 30-minute outage.
The traps to watch for:
- Build on Node 20, deploy on Node 22. The
node_modulesdirectory has compiled artifacts for Node 20’s ABI. They do not load under Node 22. The build will appear to succeed; the runtime will crash withNODE_MODULE_VERSIONmismatch errors. npm cidoes not fix this.npm cireads the lockfile and rebuilds dependencies, but it uses the Node version that ran the command. The mismatch is between the build Node and the runtime Node, not between the lockfile and the install.- The build cache is the same trap. A cached
node_modulesfrom the previous Node major is a poisoned well. The cache hit saves thirty seconds; the runtime crash costs an hour.
The fix is to delete the build cache when the Node version changes, or to rebuild node_modules as part of the deploy so the cache is always tied to the current Node major. Most CI platforms let you key the cache by the Node version so the invalidation is automatic. Set that. The first time it saves you from a 2am page is the last time you wonder whether it is worth the setup.
For a deeper look at how platform build caches and Node version pinning interact in production, the guide to checking the Node version on a running service covers the runtime side of the same problem.
The CI step that is easy to skip
The most common failure I have seen on “we updated Node and now CI is red” is a missing CI step. The team updates Node locally, runs the tests locally, pushes the branch, and watches CI fail because the runner is still on Node 18.
The fix is a CI step that does the same thing nvm use does locally:
# GitHub Actions
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
That single line ties the CI Node version to the project’s .nvmrc. Bump the file, the CI follows. Skip the line, and the CI runs whatever the runner image has, which is whatever was current when the runner image was last built. The two will drift. They always drift.
A useful audit: run the test suite with the wrong Node version and see what fails. If everything still passes, the team does not actually depend on a specific Node version, and the .nvmrc is a convention rather than a requirement. That is fine — but it should be an explicit decision, not an accident.
The opinion this post is built on
The most common reason an upgrade from Node 20 to Node 22 takes a week is not the upgrade. It is the missing version pin. The team updates Node on one machine, the deploy keeps using Node 20, and the gap is invisible until somebody asks why the new feature works locally and fails in production.
The fix is small and boring: a .nvmrc file with one number, an engines field with one line, and a CI step that does what the local shell does. The fix takes ten minutes. The next upgrade takes the same ten minutes. That is the only honest return on a Node version management investment.
If the deploy platform reads .nvmrc, exposes the runtime version, rebuilds the build cache on Node version changes, and runs as a non-root container with a slim base image, the engineer does not have to think about any of this. If the deploy platform does not, the engineer will think about it, repeatedly, for as long as the project is on that platform. The version manager is half the answer. The platform is the other half.
Before you upgrade, run the project’s tests under the new Node major on a scratch branch. Before you commit the upgrade to the deploy, run the build under the new Node major in CI. Before you ship the upgrade to production, make sure the platform’s base image matches. Three checks, ten minutes each, and the upgrade is uneventful. Skip any of them and the upgrade is the story you tell for the next quarter.
If you are sizing the cost of the new deploy against what you were paying before, the hosting cost calculator will give you a real number to compare against. The number for an upgraded runtime is rarely higher; the number for a runtime that does not match the project’s pinned version is always higher eventually.
Ship the upgrade with the version pinned, the deploy aligned, the CI matching, and the native modules rebuilt. The version bump is the easy part. The discipline around it is the work.
FAQ
What is the easiest way to update Node.js on macOS?
The easiest way is to install a version manager (brew install nvm or brew install fnm), then use it to install and switch Node versions. Avoid installing Node through the official .pkg installer and through Homebrew at the same time — you will end up with two Node installations and a PATH that picks the wrong one.
Should I use nvm, fnm, or Volta?
For most teams, nvm is the right default because every CI runner has it and every Stack Overflow answer assumes it. fnm is the more correct option for teams willing to set up a CI step. Volta is the right choice if your team keeps breaking builds because of mismatched npm or pnpm versions — it pins the tools, not just the runtime. The wrong answer is “no version manager” because that ends in version drift between local, CI, and deploy.
Does updating Node.js break my project?
It can, especially across major versions (Node 18 → 20, Node 20 → 22). Common breakages include native modules compiled against the old ABI, deprecated APIs that have been removed, and dependency engines fields that exclude the new version. Pin the new version in .nvmrc and engines, run the test suite, rebuild native modules, and check the deploy runtime before shipping.
Where does the new Node version come from on a PaaS?
It comes from the platform’s default runtime image, unless you set it explicitly. Most PaaS platforms let you pin the Node version in the platform config or read it from .nvmrc or a NODE_VERSION env var. If the platform does not let you set the Node version, that is a sign the platform is making the choice for you, and you will not know about it until the day the upgrade breaks your build.
Should I commit .nvmrc to the repo?
Yes, always. A one-line .nvmrc file is the simplest way to align local, CI, and deploy on the same Node version. Pair it with the engines field in package.json so npm yells when the wrong Node is running, and with a CI step that uses the file to set the runner version.
What is the difference between nvm install and nvm use?
nvm install 22 downloads and installs Node 22 on your machine. nvm use 22 makes the current shell use the already-installed Node 22. nvm alias default 22 makes every new shell use Node 22. For a new project you usually want all three: install, set the default, and let .nvmrc trigger the use automatically.