[{"data":1,"prerenderedAt":54},["ShallowReactive",2],{"$fhtEVgCY0ZZ54ll2WBuB5sPyQ72jxf_Jn5ur38sKEok8":3,"$fIf8gyPJ2pAZagUB-82gPI5lHKUG8YKkHAvV6DH57Ebo":32},{"_id":4,"title":5,"slug":6,"contentType":7,"excerpt":8,"tags":9,"status":13,"seo":14,"coverImage":18,"viewCount":19,"likeCount":20,"readingTime":21,"defaultLanguage":22,"createdAt":23,"updatedAt":24,"publishedAt":25,"author":26,"content":28,"availableLanguages":29},"6a86af50d4d07b7fff86abd3","The Node.js inconsistency that sat open for four years","nodejs-timerify-inconsistency","html","timerify() skips the histogram when a function throws, but records it when an async one rejects. A confirmed bug that waited four years.",[10,11,12],"Node.js","Javascript","Open Source","published",{"metaTitle":15,"metaDescription":16,"metaKeywords":17},"Node.js timerify() inconsistency explained","Why timerify() records rejected promises but not sync throws: Promise.finally vs then, the minimal thenable contract, and a four-year-old Node issue.",[10,11,12],"https://cdn.batuhanbas.dev/blog/covers/1787211312262-8079970a-061a-4ba2-be7c-648d674c126d.webp",156,11,5,"tr","2026-08-20T07:40:00.768Z","2026-09-29T17:14:06.539Z","2026-08-20T07:40:00.769Z",{"name":27},"Batuhan Baş","\u003Cp>In April 2022, someone filed an issue against Node.js. The \u003Ccode>timerify()\u003C/code> function in \u003Ccode>perf_hooks\u003C/code> skipped the histogram record when a wrapped function threw synchronously, but recorded it when an async function rejected. Same logic, two different behaviors.\u003C/p>\u003Cp>The person who filed it didn't just describe the problem. They wrote the fix into the issue body. A maintainer added the \u003Ccode>confirmed-bug\u003C/code> label.\u003C/p>\u003Cp>Then it sat there for four years.\u003C/p>\u003Cp>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.\u003C/p>\u003Ch2>What timerify does\u003C/h2>\u003Cp>Quick background if you haven't used it. \u003Ccode>performance.timerify()\u003C/code> wraps a function and measures how long each call takes. Pass it a histogram and the durations accumulate there in nanoseconds.\u003C/p>\u003Cpre class=\"bg-[#101a3a] text-[#dce6ff] rounded-[2px] p-4 font-mono text-sm\">\u003Ccode class=\"language-js\">const { performance, createHistogram } = require('node:perf_hooks');\n\nconst h = createHistogram();\nconst timed = performance.timerify(compute, { histogram: h });\n\ntimed();\ntimed();\nconsole.log(h.mean, h.count);\n\u003C/code>\u003C/pre>\u003Cp>A small profiling tool. Useful when you want to know whether a function is actually slow or you just think it is.\u003C/p>\u003Ch2>The inconsistency\u003C/h2>\u003Cp>Two functions. Both do the same thing, both throw. One is sync, one is async.\u003C/p>\u003Cpre class=\"bg-[#101a3a] text-[#dce6ff] rounded-[2px] p-4 font-mono text-sm\">\u003Ccode class=\"language-js\">function f1() { throw new Error(); }\nasync function f2() { throw new Error(); }\n\nconst h1 = createHistogram();\nconst h2 = createHistogram();\n\nconst g1 = performance.timerify(f1, { histogram: h1 });\nconst g2 = performance.timerify(f2, { histogram: h2 });\n\ntry { g1(); } catch {}\nawait g2().catch(() =&gt; {});\n\nh1.count === h2.count; // false\n\u003C/code>\u003C/pre>\u003Cp>\u003Ccode>h1.count\u003C/code> is zero. \u003Ccode>h2.count\u003C/code> is one.\u003C/p>\u003Cp>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.\u003C/p>\u003Ch2>Why it happened\u003C/h2>\u003Cp>One line explains it. When the wrapped function returned a promise, \u003Ccode>timerify\u003C/code> recorded through \u003Ccode>Promise.prototype.finally()\u003C/code>.\u003C/p>\u003Cpre class=\"bg-[#101a3a] text-[#dce6ff] rounded-[2px] p-4 font-mono text-sm\">\u003Ccode class=\"language-js\">if (typeof result?.finally === 'function')\n  return result.finally(() =&gt; { /* record */ });\n\u003C/code>\u003C/pre>\u003Cp>\u003Ccode>finally()\u003C/code> 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.\u003C/p>\u003Cp>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.\u003C/p>\u003Ch2>The more interesting part\u003C/h2>\u003Cp>The original reporter raised a point that I found more instructive than the bug itself.\u003C/p>\u003Cp>In JavaScript, being a thenable requires exactly one thing: a \u003Ccode>then\u003C/code> method. The spec asks for nothing else. \u003Ccode>finally\u003C/code> exists on \u003Ccode>Promise.prototype\u003C/code>, but if you hand-roll a thenable and implement only \u003Ccode>then\u003C/code>, you have written something completely valid.\u003C/p>\u003Cp>Which means \u003Ccode>typeof result?.finally === 'function'\u003C/code> 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.\u003C/p>\u003Cp>I stopped for a while when I read that, because I do promise checks in my own code and I check for \u003Ccode>then\u003C/code>, but I had never articulated why.\u003C/p>\u003Ch2>The fix\u003C/h2>\u003Cp>Replace \u003Ccode>finally()\u003C/code> with a \u003Ccode>then()\u003C/code> that only has an \u003Ccode>onFulfilled\u003C/code> handler.\u003C/p>\u003Cpre class=\"bg-[#101a3a] text-[#dce6ff] rounded-[2px] p-4 font-mono text-sm\">\u003Ccode class=\"language-js\">return result.then((value) =&gt; {\n  // record\n  return value;\n});\n\u003C/code>\u003C/pre>\u003Cp>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.\u003C/p>\u003Cp>For tests, I added a file asserting that a rejected thenable produces neither a histogram record nor a \u003Ccode>'function'\u003C/code> performance entry. I also ran the existing \u003Ccode>test-perf-hooks-timerify-*\u003C/code> suite locally and everything passed.\u003C/p>\u003Cp>The whole diff is a few lines.\u003C/p>\u003Ch2>Why four years\u003C/h2>\u003Cp>I asked myself this, and \"because it was hard\" clearly isn't the answer. The fix was written in the issue.\u003C/p>\u003Cp>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 \u003Ccode>confirmed-bug\u003C/code> isn't a commitment. It's an acknowledgment. Nothing moves until a person claims it.\u003C/p>\u003Cp>There's another wrinkle I didn't expect. The Node docs still state that a \u003Ccode>finally\u003C/code> 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.\u003C/p>\u003Cp>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.\u003C/p>\u003Ch2>Where it stands\u003C/h2>\u003Cp>The PR is open. It has a \u003Ccode>needs-ci\u003C/code> label and no maintainer review yet. One developer tried it and reported that it works. That's all so far.\u003C/p>\u003Cp>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.\u003C/p>\u003Cp>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.\u003C/p>\u003Chr>\u003Cp>\u003Cem>PR: \u003C/em>\u003Ca target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-primary-600 hover:text-primary underline\" href=\"https://github.com/nodejs/node/pull/65120\">\u003Cem>nodejs/node#65120\u003C/em>\u003C/a>\u003Cem> · Issue: \u003C/em>\u003Ca target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-primary-600 hover:text-primary underline\" href=\"https://github.com/nodejs/node/issues/42743\">\u003Cem>nodejs/node#42743\u003C/em>\u003C/a>\u003C/p>",[30,22,31],"en","ar",[33],{"_id":34,"title":35,"slug":36,"contentType":7,"excerpt":37,"tags":38,"status":13,"seo":42,"viewCount":46,"likeCount":47,"readingTime":48,"defaultLanguage":22,"createdAt":49,"updatedAt":50,"coverImage":51,"publishedAt":49,"author":52,"availableLanguages":53},"6a896a6cf2037887a38e21ec","Contributions have never been higher. That's the problem.","open-source-maintainer-crisis","Open source contributions hit records in 2025. In January 2026, four major projects narrowed their doors. Both are true, for the same reason.",[12,39,40,41],"AI","Software Culture","curl",{"metaTitle":43,"metaDescription":44,"metaKeywords":45},"The open source maintainer crisis, explained","Why curl ended its bug bounty, what AI-generated contributions broke on the maintainer side, and why record contribution volume and closures happen together.",[12,39,40,41],105,16,6,"2026-08-22T09:22:52.568Z","2026-09-29T18:53:25.570Z","https://cdn.batuhanbas.dev/blog/covers/1787390837814-a7c5a567-dc28-4d4b-a89a-335db0e70085.webp",{"name":27},[30,22,31],1790708004082]