Subham Haldar
article

Where does the server.listen() callback actually run?

Nov 2, 20262 min read

Every explanation of the Node.js event loop walks through the same phases: timers, pending callbacks, poll, check, close. None of the ones I read said which of them runs the callback I pass to server.listen(). So I cloned the Node.js source and read it.

The short answer

None of them. listen() binds the socket synchronously, then schedules the 'listening' event with process.nextTick(). Your callback runs from the nextTick queue: before promise callbacks, and before the event loop enters any phase.

Step 1: the callback becomes a once listener

lib/net.js
Server.prototype.listen = function(...args) {
  const normalized = normalizeArgs(args);
  let options = normalized[0];
  const cb = normalized[1];
  // …
  if (cb !== null) {
    this.once('listening', cb);
  }
  // …
};

So server.listen(3000, cb) is really server.once('listening', cb) followed by a listen. The real question is when 'listening' gets emitted.

Step 2: the port is bound before anything is deferred

In a normal (non-cluster) process, listen() ends up in setupListenHandle(). That creates the server handle and calls this._handle.listen(). Both are synchronous calls into libuv, so the port is already bound by the time listen() returns. The last thing the function does is defer the event:

lib/net.js
function setupListenHandle(address, port, addressType, backlog, fd, flags) {
  // … create the handle, then:
  const err = this._handle.listen(backlog || 511);
  // …
  defaultTriggerAsyncIdScope(this[async_id_symbol],
                             process.nextTick,
                             emitListeningNT,
                             this);
}

function emitListeningNT(self) {
  // Ensure handle hasn't closed
  if (self._handle)
    self.emit('listening');
}

defaultTriggerAsyncIdScope only sets which async ID async_hooks will see. The scheduling itself is plain process.nextTick(emitListeningNT, this).

Proving it

listen-order.js
const http = require("node:http");
const server = http.createServer();

setTimeout(() => console.log("4. setTimeout"), 0);
Promise.resolve().then(() => console.log("3. promise microtask"));
process.nextTick(() => console.log("1. nextTick queued before listen()"));

server.listen(0, () => {
  console.log("2. listen callback");
  server.close();
});

console.log("0. synchronous code");
text
0. synchronous code
1. nextTick queued before listen()
2. listen callback
3. promise microtask
4. setTimeout

The callback runs right after the nextTick that was queued before it, because the queue is first in, first out. It also runs before the promise callback, because Node drains the nextTick queue before the promise microtask queue. The timer fires only once the loop reaches its timers phase.

Why defer at all?

If 'listening' were emitted synchronously inside listen(), a listener attached on the next line would never fire: server.listen(3000); server.on('listening', …). Deferring with process.nextTick makes the event asynchronous while still firing before any I/O. Errors take the same route: when binding fails, emitErrorNT is also queued with process.nextTick, so an 'error' handler attached after listen() still catches EADDRINUSE.

What I took from it

Docs explain the event loop. The source explains your particular code. About ten minutes in lib/net.js answered a question that none of the explanations I found had stated directly.

This started as a shorter LinkedIn post.

Tags:Node.jsEvent loop