Skip to content

Node.js 26: 'end' is not delivered until another JS callback runs #454

Description

@dr-dimitru

With node-libcurl 5.1.2 on Node.js 26.10.0 (macOS arm64 and Ubuntu CI), a finished transfer does not emit end until some other JavaScript callback runs in the process. libcurl has already received the full response by then. The same code on Node.js 24.16.0 emits end right away. Both Node.js versions bundle libuv 1.52.1.

Repro

import { openSync } from 'node:fs';
import { Curl } from 'node-libcurl';

// Any HTTP server in another process, e.g.:
// node -e "require('http').createServer((q,s)=>{q.resume();q.on('end',()=>s.end('ok'))}).listen(47126)"
const start = Date.now();
const curl = new Curl();
curl.setOpt('URL', 'http://127.0.0.1:47126/');
curl.setOpt('UPLOAD', true);
curl.setOpt('READDATA', openSync('some-file', 'r'));
curl.setOpt('NOPROGRESS', false);
let seen = false;
curl.setProgressCallback((_dlt, dl) => {
  if (dl > 0 && !seen) { seen = true; console.log('body received', Date.now() - start); }
  return 0;
});
curl.on('end', () => { console.log(process.version, 'end', Date.now() - start); process.exit(0); });
curl.perform();

if (process.argv[2] === 'tick') setInterval(() => {}, 300);
setTimeout(() => { console.log('still pending after 4 s'); process.exit(2); }, 4000);
Run body received end
Node.js 24.16.0 1 ms 2 ms
Node.js 26.10.0 4 ms never (still pending after 4 s)
Node.js 26.10.0 with tick 3 ms 303 ms, the first interval tick

end fires on the first unrelated timer tick. So the completion looks like it is queued as a microtask, probably the multi.perform() promise being resolved from a libuv poll or timer callback, and the queue is not drained until the next JS callback. My guess is that the resolve in Multi::CallOnMessageCallback runs without a callback scope (Napi::CallbackScope or napi_open_callback_scope), and Node.js 26 no longer drains microtasks after such native callbacks. I have not verified this.

Other effects

  • GET requests to an HTTP server in the same process: once the keep-alive connection is reused, each transfer takes about 1000 ms instead of about 1 ms. FORBID_REUSE: true avoids it. FRESH_CONNECT: true does not.
  • If the caller closes the handle while end is still pending (for example, after its own timeout), the queued onEnd later throws an uncaught CurlEasyError: Curl handle is closed at this.handle.getInfo(Curl.info.RESPONSE_CODE) (dist/Curl.js:224), which crashes the process.

Found while testing request-libcurl: veliovgroup/request-extra#16

Activity

  1. changed the title [-]Node.js 26: transfers stall until the ~1 s fallback timer (in-process server, READDATA fd uploads)[/-] [+]Node.js 26: 'end' is not delivered until another JS callback runs[/+] on Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions