27 July 2026
Bumping Node in production: what I learned the hard way
A major Node version upgrade seemed routine. A combination of network timing changes and dependency updates turned it into a harder problem than expected.
In an enterprise environment, software engineers are expected to handle devops and infra tasks as part of normal delivery work. Bumping the Node version is one of those tasks that looks routine on the surface, and occasionally is not.
When Node LTS reached end of life, we needed to move to the next major version. The upgrade itself went smoothly. A few days later, after an unrelated dependency bump, things started failing. This is what I learned from debugging it, and what I now ask before every Node version upgrade.
A timing problem hiding between two separate changes
The Node upgrade went in without issues. For a few days everything looked fine. Then a separate dependency update landed: a library that switched its underlying HTTP client from an older fetch implementation to native fetch via undici, alongside another that removed an abstraction layer in its request handling. Shortly after, failures started appearing.
The root cause was a combination of three changes that were individually harmless but together created a gap.
The premature close: what the gap looks like
When a server reuses an HTTP connection, there is a small window where the remote service may have already closed that connection on its end while the local network stack has not yet processed the signal.
The sequence looks like this: the remote server closes the connection at time T. The local network stack processes that signal at T plus a few milliseconds. If the server tries to reuse the connection inside that gap, it sends a request down a connection that has already been hung up on the other end. The result is a premature close error.
undici, unlike the older fetch implementation it replaced, holds onto connections more aggressively and does not handle that timing gap the same way. The new Node version changed socket and timing handling in a way that widened the window further. Neither change was a bug in isolation. The failure was the interaction between them, and the delay between the Node bump and the dependency bump made the root cause genuinely hard to see at first.
What to ask before bumping Node
This experience gave me a set of questions I now ask before any Node version upgrade. Networking and HTTP behaviour is the category that caught me here, but it is only one of several.
Network and HTTP behaviour
Ask: what changed in socket, keep-alive, and HTTP handling between the current and target version? Do any integrations rely on keep-alive connections or long-lived HTTP sessions? Is there a way to test this under realistic load before going to production?
Node can change timing behaviour that only surfaces under specific conditions: a long-lived connection to a service that closes quietly on its end. This kind of regression does not show up in unit tests. It shows up in production under load, with a delay long enough to obscure the cause.
V8 engine changes
Node ships with V8. Between major versions, V8 changes how it compiles and optimises JavaScript. This can affect performance in both directions, memory usage and garbage collection behaviour, and in rare cases edge cases in JS semantics.
Performance regressions from a V8 change are particularly deceptive because they show up as slowness rather than errors. Baseline your key metrics before upgrading so you have something to compare against.
Breaking changes in built-in modules
Core modules like fs, crypto, stream, buffer, and path get updates with every major release. APIs deprecated in one major version may be removed in the next. If any dependencies used those deprecated APIs internally, they break silently: no error at install time, just a failure at runtime when that code path executes.
Check the Node release notes for removals, not just additions. Then check your dependency tree for packages old enough to have used the deprecated APIs.
Native addons
Some npm packages compile native C++ code against a specific Node ABI (application binary interface). When you bump the Node version, those binaries often need to be recompiled against the new ABI. If they are not, they either crash on load or refuse to load entirely.
Run npm rebuild after upgrading and watch for any packages that fail. Packages with native addons include things like bcrypt, sharp, canvas, and some database drivers.
OpenSSL version changes
Node bundles OpenSSL, and major Node bumps often bring a new OpenSSL version. This can change which TLS versions and cipher suites are accepted, certificate validation strictness, and which crypto algorithms are available. Some older algorithms get disabled by default in newer OpenSSL releases.
If your service talks to any external APIs or internal services over HTTPS, or if you use Node's crypto module directly, an OpenSSL bump is worth checking explicitly.
npm and package resolution changes
Node ships with a specific npm version. A newer npm might resolve dependency trees differently, pulling in different versions of transitive dependencies. This is similar to what happened here: a dependency update created a transitive interaction that only surfaced after the Node bump.
After upgrading, do a full npm install and review your package-lock.json diff for unexpected transitive version changes.
ESM and CommonJS behaviour
Node's handling of ES modules versus CommonJS has evolved across versions. Code that worked in mixed CJS/ESM environments in one major version might behave differently in the next. This is particularly relevant if any packages in your tree are in the middle of their own ESM migration.
Look for packages that ship dual CJS/ESM builds and check whether their behaviour under the new Node version is documented.
What to change for a major Node upgrade
Bump the Dockerfile
Change the Node version in both the builder and target stages. Keeping them in sync is essential: mismatched versions between build and runtime are a common source of failures that only appear in production.
FROM node:lts-alpine AS builder
# ...
FROM node:lts-alpine
Add .nvmrc to the project root
A .nvmrc file at the project root means everyone on the team gets the right version automatically when they run nvm use. Without this, local Node versions drift silently and issues that only appear on the new version become hard to reproduce.
lts/*
Add engines to package.json
Declaring the expected Node version range in engines makes the requirement explicit and gives tooling and contributors a clear signal.
{
"engines": {
"node": ">=lts"
}
}
Verify locally before shipping
After bumping your local Node version, run these four checks in order:
nvm use
node --version
tsc --noEmit
tsc && tsc-alias -p tsconfig.json
vitest run
If all four pass, it is safe to ship. The type-check is the most valuable one specifically for a Node version bump. It surfaces any type-level incompatibilities introduced by updated @types/node definitions, which track Node's actual API surface. A clean type-check does not guarantee no runtime issues, but it eliminates a large class of them cheaply.
Closing
Final thoughts
The upgrade looked routine. The failure did not come from the upgrade itself. It came from the interaction between the upgrade and a separate dependency change that landed a few days later, mediated by a timing window that only exists under load.
The lesson is not to be afraid of Node upgrades. It is to treat them as a category of change with specific risk areas, each of which deserves a deliberate check rather than an assumption that the tests will catch everything.
The checklist above addresses the mechanical steps. The mental model covers networking, V8, built-ins, native addons, OpenSSL, package resolution, and ESM. That is what you reach for when something breaks and you are trying to understand why. Build both into your upgrade process and the next one will be considerably less eventful.