How a Routine Node 14 to Node 18 Upgrade Turned a Six-Year-Old Bug Into a Crash Loop
← Back
November 2, 2026NodeJS9 min read

How a Routine Node 14 to Node 18 Upgrade Turned a Six-Year-Old Bug Into a Crash Loop

Published November 2, 20269 min read

09:14 UTC. PagerDuty: "inventory-sync: 0/4 tasks healthy." The deploy that shipped twenty minutes earlier wasn't a feature, it was a base image bump, Node 14 to Node 18, done purely to clear a security scan finding. No application code changed. The diff was eleven lines, all of them in a Dockerfile.


the scramble

First theory: the new Node image broke something in how the container starts. Node 18 changed its default OpenSSL provider, and there's a well-known class of "error:0308010C:digital envelope routines" crashes that hit older crypto code on the jump past Node 17. Someone greps the crash logs for that string. Nothing. The process isn't failing to start, it's starting fine, running for anywhere between ninety seconds and four minutes, then dying outright.

Second theory: memory. Node 18's default heap sizing behaves differently than Node 14's on the same container memory limit, and a tighter effective heap could plausibly OOM a service that was already close to its ceiling. kubectl describe pod says otherwise, exit code 1, not 137. Whatever killed these processes, it wasn't the kernel's OOM killer and it wasn't a Node heap limit. The process chose to exit.

Rolling back the image to Node 14 makes the crashes stop immediately, which rules out "unrelated bad luck" and confirms the upgrade really is the trigger. It doesn't yet explain how, since nobody touched the code the upgrade was supposedly isolated from.


the hunt

The crash logs all show the same two lines, which nobody had seen before because Node 14 never printed them for this code path:

inventory-sync container logs, 09:16:42 UTC
node:internal/process/promises:289
    triggerUncaughtException(err, true /* fromPromise */);
    ^
Error: write ETIMEDOUT
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20)
[... stack continues into warehouse-client.js:41 ...]
Node.js v18.18.2

warehouse-client.js:41 is a background sync loop that had existed unchanged since the service's first commit, six years earlier:

warehouse-client.js, line 30-44, unchanged since 2020
function startWarehousePoll() {
  setInterval(async () => {
    const updates = await fetchWarehouseDeltas(); // can reject on a slow upstream
    await applyToLocalCache(updates);
  }, 5000);
}

setInterval doesn't await the callback it's given and has no way to. If the async function inside it rejects, the promise that rejection belongs to was never returned to anything and never had a .catch() attached. It is, by definition, an unhandled rejection, every single time fetchWarehouseDeltas() hits a slow or dead upstream, which a warehouse API with its own maintenance windows did every few days.

That's not new. That exact rejection had been firing for years. What changed is what Node does about it:

Node.js --unhandled-rejections default, by version
Node 14: default mode = warn
  -> logs "UnhandledPromiseRejectionWarning" to stderr, process keeps running

Node 15+: default mode = throw
  -> the rejection becomes an uncaught exception, process exits

Node changed this default in Node 15, released back in 2020, specifically to stop silent promise rejections from hiding real bugs. Every LTS release since has kept throw as the default. The inventory-sync service had simply never crossed that version line until this upgrade, so a six-year-old latent bug had been quietly logging a warning line nobody read, on every slow warehouse API call, the entire time. The Node 18 jump didn't introduce a bug. It just stopped forgiving one that was already there.

The warehouse API's maintenance windows happening to land in the same hour as the deploy made the timing look causal in a way that made the Dockerfile diff the obvious, and misleading, first suspect.


the find

An async callback inside setInterval had no rejection handler and never had one. Node 14 treated that as a warning. Node 15 and every version after it treats the same rejection as a fatal, uncaught exception. The upgrade didn't break anything new, it just enforced a stricter default against six years of code that had always depended on the old, more forgiving one.


the fix

The immediate fix wraps the interval callback so a rejection becomes a logged, recoverable error instead of a process-ending one:

warehouse-client.js, after
function startWarehousePoll() {
  setInterval(() => {
    pollOnce().catch((err) => {
      log.error('warehouse poll failed, will retry next tick', err);
      pollFailures.inc();
    });
  }, 5000);
}

async function pollOnce() {
  const updates = await fetchWarehouseDeltas();
  await applyToLocalCache(updates);
}

Second, a process-level safety net for the next one of these, since this was unlikely to be the only unattached promise in a six-year-old codebase:

server.js, startup
process.on('unhandledRejection', (reason, promise) => {
  log.error('unhandled rejection, see stack below', reason);
  unhandledRejections.inc();
  // deliberately not exiting here: log and alert, let the
  // orchestrator's health checks decide if the process is actually unhealthy
});

Third, an ESLint rule that would have caught this years before any Node upgrade forced the issue:

.eslintrc.js
rules: {
  '@typescript-eslint/no-floating-promises': 'error',
  '@typescript-eslint/no-misused-promises': 'error',
}

Both rules flag exactly this pattern, an async function handed to something that can't await it, with no explicit .catch(). Running it against the existing codebase after the incident turned up four more floating promises of the same shape, none of which had caused an outage yet only because their upstreams hadn't had a bad day since the Node 15 line was crossed.


the aftermath

6 years the unhandled rejection existed before a Node version crossed the line that made it fatal
22 min from first crash loop to rollback restoring service

Inventory sync being down for 22 minutes meant warehouse stock deltas queued instead of applying live; nothing was lost, the backlog drained in under two minutes once the fixed image rolled forward, and no order was accepted against stale inventory because the checkout service's own stock check hits a separate, unaffected read path.

  • A Node major version upgrade is not "just a runtime bump" when the runtime's defaults change. Node's own release notes for 15 through 18 list every behavioral default change, and --unhandled-rejections was in there the whole time; nobody reads that list until after it's the reason for an incident.
  • Code that has run safely for years under a lenient default isn't correct, it's unverified. The bug existed on day one. It took a stricter runtime to make it visible, and it would have been caught the same day it was written if the lint rule had existed from the start.
  • setInterval with an async callback is a shape that looks fine in review and has no way to propagate a rejection to anything. If the interval's job can fail, the failure needs its own .catch() inside the callback, not an assumption that the runtime will be forgiving about the one you forgot.

The Dockerfile diff was eleven lines and genuinely had nothing wrong with it. The bug it exposed was committed years before anyone on the team that shipped this upgrade had joined.

Share this
← All Posts9 min read