How to Test a Node.js Major Version Upgrade Before Production
Most Node.js upgrades fail in the places your tests never look. Here is a repeatable way to find the breakage in CI instead of in production.
On this page 5 sections
To test a Node.js major version upgrade, read the release notes for removed APIs, run your test suite on the new version in CI, run a canary build against real traffic, and watch error rates before full rollout. Pin the version in your lockfile and Dockerfile so the change is deliberate and reversible.
This guide is for engineers who own a Node.js service and want a checklist they can run every release cycle. Backtrace covers runtimes and build tooling with one question in mind: what does an upgrade actually break? The steps below are evergreen, so they apply to whichever major version you are moving to next.
Why Major Node.js Upgrades Break Things Quietly
A major version is the only time the Node.js project is allowed to remove or change behavior on purpose. The breaking changes are documented, but they are scattered across the changelog, the V8 engine notes, and the deprecation list. A passing unit test suite rarely touches any of them.
Breakage hides in three places. The first is removed or changed APIs in your own code. The second is a dependency that calls such an API three layers down. The third is a native addon compiled against the old runtime that refuses to load on the new one.
The release cadence makes this a recurring chore rather than a one-time project. The Node.js project publishes its dates on the official previous releases page. Even-numbered releases move into long-term support (LTS) in the fall, and older lines eventually reach end of life and stop receiving security fixes.
That schedule is why "we will upgrade later" is not a plan. Each skipped major version doubles the size of the eventual jump. A team moving across one version reads one changelog, while a team moving across three reads three and debugs their interactions.
Pinning matters here as much as it does for infrastructure. The same discipline that keeps a Terraform run predictable, covered in our guide to the Terraform Kubernetes provider 3.4 upgrade, applies to a runtime. Name the exact version, change it in one commit, and make that commit easy to revert.
Test coverage is the other variable. A service with thin tests gains little from a CI matrix, because the matrix only proves what the tests exercise. If coverage is weak, write a handful of end-to-end checks for your most important user flows before you begin, so the old and new versions are compared on something that matters.
How Do You Find What a Node.js Upgrade Will Break?
Read the changelog for removed and changed APIs, then run the new version with deprecation warnings turned into errors. The --throw-deprecation flag stops the process at the first deprecated call, so each one gets a stack trace. Fix those first, because today's warning is often the next major version's crash.
Start with the removals. Skim the notable changes for the new major version and list every API your code imports from the core modules. Search your repository for each one. This takes an hour and finds the failures you will otherwise meet at runtime.
Next, use the runtime to find what you missed. The Node.js command-line options reference documents --throw-deprecation and --trace-deprecation. Run your test suite on the current version with the first flag set, and every hidden deprecated call fails loudly.
Dependencies are the harder half. Your code may be clean while a transitive package still calls a removed function. The Node.js deprecations list shows each deprecation by stage, from documentation-only to runtime warning to end of life. Anything at the end-of-life stage will break on the next major release.
When the trace points into node_modules, you have three options. Upgrade the package, replace it, or patch it with a tool such as patch-package until a fix ships. Check the package's issue tracker first, because someone has usually already hit the same error.
One edge case deserves a warning. A flag like --throw-deprecation only catches code paths your tests actually run. Rarely used branches, such as an admin export or a legacy webhook, can still hide a deprecated call. Add tests for those paths now, not after the upgrade.
Native Modules, Lockfiles, and Build Surprises
Native modules are the most common cause of a failed upgrade. A package with a compiled addon, such as a database driver or an image library, is built against a specific Node.js application binary interface (ABI). A new major version changes that interface, so the old binary will not load.
Delete node_modules and reinstall from the lockfile on the new version. Do not copy a directory built on the old one. If an install fails during compilation, check whether the package ships prebuilt binaries for the new version, and if it does not, whether a newer release does.
Build tools can break for related reasons. A bundler plugin that reaches into internals, or a test runner pinned to an old transform, may fail with an error that looks unrelated. Large repositories feel this most, which is one reason the build tool you choose matters, as we noted in our comparison of Nx, Turborepo, and Bazel.
Container images deserve their own check. If your Dockerfile says FROM node:lts, the upgrade can happen on a routine rebuild without anyone deciding to do it. Pin the exact major version in the image tag, and change it in the same pull request that updates the CI matrix.
Finally, confirm the lockfile is stable. Run a clean install twice and make sure it produces no diff. A lockfile that rewrites itself on the new version can pull in newer transitive packages you never reviewed.
How to Run the Upgrade Through CI and a Canary
Treat the upgrade as a release, not a configuration tweak. A safe rollout has two layers: a CI matrix that proves the code works on both versions, and a canary that proves it works under real traffic. Follow these steps in order.
- Add the new version to your CI matrix next to the current one, and keep both green for at least a week.
- Run unit, integration, and end-to-end tests on both versions, with deprecation warnings set to errors on the new one.
- Build the container image on the new version and run your smoke tests against it in a staging environment.
- Deploy to a canary that takes about five percent of traffic, and compare error rate and latency against the old version.
- Raise traffic in stages, and keep the previous image tagged and ready for a one-command rollback.
The table below shows what to compare during the canary and what usually signals trouble. Use your own baselines, because every service differs.
| Signal | Compare against | Warning sign |
|---|---|---|
| HTTP 5xx rate | Old version, same hour | Any sustained increase |
| p95 latency | Old version, same hour | More than 10 percent slower |
| Memory after one hour | Old version, same traffic | Steady climb with no plateau |
| Process restarts | Old version | Any crash loop |
| Unhandled rejection logs | Old version | New error messages |
Smoke tests are the cheapest layer here. A short script that hits login, a write, and a read catches a broken deploy in under a minute, a pattern laid out in our piece on how smoke tests catch broken deployments. Run it against staging before the canary and again after each traffic step.
Memory deserves a patient eye. A leak or a changed garbage collection pattern may take an hour to show, so a canary that is judged after five minutes proves little. Give it a full traffic cycle if you can.
A fair counterpoint: for a small internal tool, this full process can be overkill. A hobby service with one user does not need a canary. A CI matrix and a quick manual check are enough, and the effort should scale with the cost of an outage.
Write down the result of each upgrade in a short log. Note which packages needed a new release, which flags you used, and how long the canary ran. The next upgrade will be faster because the list of known trouble spots is already written, and a new teammate can follow it without asking anyone.
Assign one owner to the work, too. Upgrades that belong to everyone tend to stall for months. A single named engineer who owns the CI matrix, the changelog review, and the canary decision keeps the project moving and gives the rest of the team one person to ask.
Conclusion
A Node.js major version upgrade stays safe when it is small, scheduled, and reversible. Read removals first, make deprecations fail in CI, rebuild native modules, and let a canary prove the change under real load.
Your next step is to add the upcoming version to your CI matrix today, even if you will not ship it for months. Backtrace editors who follow this habit find that the matrix surfaces problems early, when the fix is a small pull request rather than an emergency.