The Node.js Upgrade We Put Off Until a CVE Forced Our Hand

A client's API had been running Node 16 for two years past end-of-life. When a critical OpenSSL vulnerability dropped, the "we'll upgrade next quarter" plan collapsed into a three-week fire drill. The upgrade itself wasn't hard. Undoing two years of drift was.


The Slack message from the VP of Engineering was short: "Can you help us with a security remediation? Should be a quick one." In consulting, that sentence has never once been true.

The company was a mid-size B2B SaaS provider, about 60 engineers, with a core API that handled contract management and e-signatures. The API was a Node.js monolith — well-structured, decent test coverage, nothing unusual. Except it was running Node 16.

Node 16 reached end-of-life in September 2023. This was March 2026. Two and a half years without security patches, performance improvements, or runtime fixes. And now a CVE in OpenSSL — the version bundled with Node 16's build — had given them thirty days to remediate or fail their next SOC 2 audit.

How a two-day task becomes a three-week project

In isolation, upgrading Node.js is boring work. You bump the version in your Dockerfile, update .nvmrc, run the tests, fix whatever breaks. Teams that stay current spend a day or two on each LTS bump. Maybe less.

But this team hadn't touched the runtime in two and a half years. And that gap doesn't grow linearly — it compounds.

The first thing I did was try to build the project with Node 20. Not even Node 22, which was current LTS. Just Node 20, as a stepping stone. The build failed in fourteen seconds. node-gyp couldn't compile three native dependencies. Two of them were unmaintained packages with no Node 20 support. The third was bcrypt at a version so old it predated the N-API migration.

gyp ERR! build error
gyp ERR! stack Error: `make` failed with exit code: 2
node-pre-gyp ERR! build error
node-pre-gyp ERR! not ok

This is the screen the team had seen every time someone tried the upgrade over the past year. They'd file a ticket, estimate it at two weeks, lose the sprint to feature work, and push it to next quarter. Each quarter, the list of broken things grew slightly longer.

The dependency audit nobody wanted

I spent the first three days just cataloguing what was broken and why. The results weren't pretty.

The bcrypt package needed to jump from 5.0.1 to 5.1.1. That part was simple — same API, just a rebuild. But a PDF signing library the team relied on used a native OpenSSL binding that only supported Node 14-16. The maintainer had archived the repo eight months earlier. There was a community fork with Node 20 support, but it changed the initialization API.

Then there was a homegrown metrics collector that used process.binding('natives'), an internal V8 hook that was removed in Node 18. Nobody remembered who wrote it or exactly what it reported. It had been silently swallowing errors for months because someone had wrapped the call in a try/catch that returned an empty object on failure.

Warning

If you're using process.binding() in production code, stop. It was never a public API, and every major Node.js upgrade since v18 has removed more of these internal hooks.

The dependency tree had 1,847 packages. Forty-three of them had Node engine constraints that excluded Node 20. Twelve of those were direct dependencies. The rest were transitive, buried three or four levels deep in the dependency graph, and some of them conflicted with each other on which version ranges they'd accept.

Week two: the subtle stuff

Once the build worked — after replacing two packages, forking one, and rewriting the metrics collector — I expected the test suite to be the remaining battleground. It was, but not in the ways I predicted.

The obvious failures were fine. A handful of tests that relied on util.types.isSharedArrayBuffer behavior that changed in Node 18 were easy to fix. A streaming test that depended on specific backpressure timing needed a setImmediate added.

The subtle failures were worse. Node 18 changed the default minimum TLS version to 1.2. The API integrated with a legacy document storage service run by one of the client's enterprise customers. That service only supported TLS 1.0. The tests passed because the test mocks didn't negotiate real TLS. But in staging, every call to that integration returned ECONNRESET.

We spent almost a full day on that one before someone thought to check the TLS version. The fix was a targeted override for that specific integration — not ideal, but the customer was six months away from decommissioning the old service, and the alternative was telling a seven-figure account that their integration was broken.

Another day went to Buffer.from() encoding behavior. Node 20 is stricter about invalid UTF-8 sequences. One of the contract parsing endpoints received binary PDF data as a base64 string, decoded it, processed the document, then re-encoded it. The round-trip worked in Node 16 because it silently replaced invalid bytes. Node 20 threw. The data hadn't changed — it had always contained a handful of malformed bytes from a specific scanner model that one customer used. It just never mattered before.

The real cost

Three weeks. Two engineers pulled off feature work, plus my time. The upgrade itself — changing the Node version, updating the Dockerfile, adjusting CI — took about four hours. The remaining nineteen and a half days were spent on everything around it: replacing abandoned packages, rewriting code that relied on undocumented internals, chasing integration-level behavior changes that no unit test would ever catch.

If the team had gone from 16 to 18 when it was released, they'd have hit maybe two of these issues. Then 18 to 20 would have surfaced two more. Each step would have been a day or two of manageable work, handled in normal sprint flow. Instead, all of those problems landed at once, under a thirty-day compliance deadline, with no room to defer any of them.

The pattern I keep seeing

This is the fifth time in three years I've been called in for some version of this exact project. Different runtimes — Node, Python, Java — but the same shape. A team defers an upgrade because it's never the most urgent thing. The gap widens. Each passing quarter makes the upgrade slightly harder, which makes it slightly easier to justify deferring again. Then something external — a CVE, a compliance audit, a cloud provider dropping support — removes the option to wait.

The teams aren't negligent. They're making a rational short-term call every single sprint: ship the feature that customers are asking for, or spend a week on infrastructure work that has no visible benefit. The problem is that those rational short-term calls accumulate into an irrational long-term position.

I don't have a clean answer for how to fix the incentive structure. Dependabot and Renovate help with the mechanical part, but the hard part isn't knowing an upgrade is available — it's getting organizational buy-in to prioritize work that prevents problems nobody can see yet.

Maybe the most honest advice is this: if you're reading this and your production runtime is more than one major version behind current LTS, go file the ticket now. Not because the upgrade is exciting. Because the version of this project that's a two-day task today is a three-week emergency in eighteen months.