0014. io.on('connection') fires the server side before the client connect
Status: Accepted · 2026-07-31 · #88, #350
TL;DR
io.on('connection')is smocket's server-side app entry point. Each accepted middleware completion fires it before the corresponding clientconnect, through the same onedefer; repeated completion reuses the same Socket. It arrives withconnect(url)and the missing-server path as one timeline.
Decision
io.on('connection', cb) runs cb for each accepted middleware completion,
socket.io's primary way to wire per-socket handlers, and the on-based counterpart to
the nextConnection path. A middleware normally completes once. If it invokes the
same next repeatedly, Socket.IO reuses one server Socket while firing connection
and client connect for every accepted completion. io.on(...) targets the default
namespace, exactly io.of('/').on(...), so it never sees a connection on another namespace.
The handler fires before that connection's client connect. Within the one
deferred step that completes a pairing (0004),
the server socket is registered and joins its id-room, is offered to any
nextConnection, then the connection handlers run, and only then does the client
connect fire. This is the order real socket.io uses: the server side is
observable first, so a connection handler can already broadcast to the new
socket, which is why the socket is in the roster and its id-room before the handler
runs. Both nextConnection and on('connection') see the same socket. Repeated
middleware completion repeats the lifecycle events but does not enqueue that Socket
again in the direct connection API
(0030).
The connection event rides the same defer as the connect and the first
emits (0010), not a second clock. One
FIFO queue is what keeps the connection handler, nextConnection, and the client
connect deterministically ordered; two schedulers would give no ordering
guarantee between them.
These app-facing entry points are one bundle because their timings interlock. A
client connects by url, resolved to a server through the origin registry
(0002,
0003), and the url's path selects the namespace. When
the origin has a server, the pairing completes on the timeline above and the
connection handler runs; when it has none, the client fires connect_error and
the handler simply never runs (0005). The
handler firing, the one-tick defer, and connect_error-on-missing are the same
sequence of events, so designing them apart would split one timeline three ways.
io.on('connect') is a synonym for io.on('connection'), because real socket.io
fires both on the namespace for each new socket. Verified by a dual-run test:
listening on either reaches the same connection, so an app written against
io.on('connect') runs on smocket unchanged. Beyond those two, no event name has a
source on the namespace (0000): they are accepted but never fire, because a mock has
nothing else to raise there.
The origin registry (0003) is keyed by normalized
origin, and a second new Server(url) for the same origin overwrites the first,
last write wins. There is no real behaviour to copy here — two real servers on one
port collide at the OS with EADDRINUSE, but the registry is a mock-only mechanism
with no dual-run counterpart (like the adapter, differences.md §B), so this is a
smocket design choice rather than a fidelity question. Overwrite is chosen so the
universal test pattern, a fresh new Server(url) in beforeEach, just works without
a teardown step. Because of that, the registry's reset is resetRegistry, exported
from mock-server.ts but deliberately not re-exported from the package index: it
is a test-only affordance for a suite that registers servers and needs lookups
isolated between cases, not part of the 1.0 public surface.
Alternatives rejected
- Fire
connectionafter, or at the same time as, the clientconnect. A handler that broadcasts to the new socket would then race the client's ownconnect, and real socket.io makes the server side observable first, so the after/simultaneous order would disagree with the reference on the ordering aconnectionhandler depends on. - Put the
connectionevent on its own scheduler. A separate clock from the connect and the first emits gives no ordering guarantee between them, the same reason 0010 keeps onedefer; aconnectionhandler could then miss the order a real server guarantees. - Expose only
nextConnectionand no realon('connection'). The direct path pairs a connect with its server socket, but code written for real socket.io wires handlers throughio.on('connection'); without it, that code cannot run on smocket at all, which is the gap this issue closes.