Node.js·5 min read

The Node.js inconsistency that sat open for four years

timerify() skips the histogram when a function throws, but records it when an async one rejects. A confirmed bug that waited four years.

August 20, 2026 · updated September 29, 2026

The Node.js inconsistency that sat open for four years

In April 2022, someone filed an issue against Node.js. The timerify() function in perf_hooks skipped the histogram record when a wrapped function threw synchronously, but recorded it when an async function rejected. Same logic, two different behaviors.

The person who filed it didn't just describe the problem. They wrote the fix into the issue body. A maintainer added the confirmed-bug label.

Then it sat there for four years.

I came across it in August 2026, and what I had in front of me was this: a confirmed bug, acknowledged by maintainers, with the solution already spelled out, sitting inside one of the most widely deployed runtimes on the planet. Nobody had picked it up.

What timerify does

Quick background if you haven't used it. performance.timerify() wraps a function and measures how long each call takes. Pass it a histogram and the durations accumulate there in nanoseconds.

const { performance, createHistogram } = require('node:perf_hooks');

const h = createHistogram();
const timed = performance.timerify(compute, { histogram: h });

timed();
timed();
console.log(h.mean, h.count);

A small profiling tool. Useful when you want to know whether a function is actually slow or you just think it is.

The inconsistency

Two functions. Both do the same thing, both throw. One is sync, one is async.

function f1() { throw new Error(); }
async function f2() { throw new Error(); }

const h1 = createHistogram();
const h2 = createHistogram();

const g1 = performance.timerify(f1, { histogram: h1 });
const g2 = performance.timerify(f2, { histogram: h2 });

try { g1(); } catch {}
await g2().catch(() => {});

h1.count === h2.count; // false

h1.count is zero. h2.count is one.

Both calls failed, but only one of them shows up in the histogram. That's a problem for a measurement tool, because you no longer know what the histogram represents. Successful calls only? Successful calls plus some failed ones? The number is there, and it's quietly wrong.

Why it happened

One line explains it. When the wrapped function returned a promise, timerify recorded through Promise.prototype.finally().

if (typeof result?.finally === 'function')
  return result.finally(() => { /* record */ });

finally() runs on fulfillment and on rejection. That's the whole point of it. The sync path works differently: when the function throws, the exception unwinds before execution ever reaches the recording step, so nothing gets written.

Two code paths, written separately, doing different things. It doesn't look like anyone decided this. It looks like nobody put the two side by side.

The more interesting part

The original reporter raised a point that I found more instructive than the bug itself.

In JavaScript, being a thenable requires exactly one thing: a then method. The spec asks for nothing else. finally exists on Promise.prototype, but if you hand-roll a thenable and implement only then, you have written something completely valid.

Which means typeof result?.finally === 'function' wasn't checking "is this async." It was separating real promises from hand-rolled thenables. A function returning a custom thenable would fail that check and drop out of measurement entirely. A second inconsistency hiding inside the first.

I stopped for a while when I read that, because I do promise checks in my own code and I check for then, but I had never articulated why.

The fix

Replace finally() with a then() that only has an onFulfilled handler.

return result.then((value) => {
  // record
  return value;
});

Three things fall out of this. Recording happens only on success, matching the sync path. Rejections still propagate through the returned promise, so nothing changes for the caller. And it stays inside the minimal thenable contract.

For tests, I added a file asserting that a rejected thenable produces neither a histogram record nor a 'function' performance entry. I also ran the existing test-perf-hooks-timerify-* suite locally and everything passed.

The whole diff is a few lines.

Why four years

I asked myself this, and "because it was hard" clearly isn't the answer. The fix was written in the issue.

Here's what I think. In open source, work gets done when someone decides it's theirs. The Node repo has thousands of open issues, maintainer time is finite, and confirmed-bug isn't a commitment. It's an acknowledgment. Nothing moves until a person claims it.

There's another wrinkle I didn't expect. The Node docs still state that a finally handler gets attached when the wrapped function returns a promise. The buggy behavior is documented. That complicates the fix, because correcting the code makes the documentation wrong.

I used to think contributing to Node core was out of reach. It isn't. There are labeled, confirmed, well-described issues sitting in that repo, and a lot of them are a weekend of work.

Where it stands

The PR is open. It has a needs-ci label and no maintainer review yet. One developer tried it and reported that it works. That's all so far.

Will it land? I don't know. It's a behavior change, so there may be a semver-major discussion, or a docs update request, or just weeks of silence. All normal for Node.

But here's what I took away: an issue staying open for four years doesn't mean it's hard. Usually it just means nobody's turn came up.


PR: nodejs/node#65120 · Issue: nodejs/node#42743

#Node.js#Javascript#Open Source

Available languages

English ✓
Share:BlueskyXLinkedIn

Related Posts