I'd like to revisit a long-standing net.Socket behavior, following an investigation and proof of concept in libuv: libuv/libuv#5308
This is closely related to nodejs/node#23858, opened in 2018, where socket.remoteAddress, remotePort, and remoteFamily could already be undefined inside the server's connection handler.
That discussion ended without a fix, but a maintainer comment also left room for reconsideration if a stronger technical case could be made.
I've tried to approach this follow-up in that spirit: I traced the behavior through the Node.js and libuv history, examined the relevant Linux kernel semantics, built a libuv proof of concept that preserves the accept-time peer address, and benchmarked its connection-throughput cost.
The findings point to an information-lifetime issue at the libuv layer. Preserving the accept-time address also has a different performance profile from restoring the old unconditional getpeername() call.
What happens
On Unix, libuv accepts incoming TCP connections without requesting the peer sockaddr from accept() / accept4(). When Node.js later needs the remote address, libuv calls getpeername() on the accepted socket.
Normally that works. However, a connection can complete successfully and then be reset before the application processes it. The distinction between obtaining the peer address as part of accept() and querying it later with getpeername() is part of the original BSD sockets API design, introduced with 4.2BSD in 1983; it is not specific to Linux. The Linux reproducer demonstrates why this distinction matters: accept() can still return the peer address even when a subsequent getpeername() fails with ENOTCONN.
Once the accept-time sockaddr has been discarded, the later query cannot recover it.
A reproducer in the libuv issue demonstrates this using a normal TCP connect() followed by an abortive close with SO_LINGER.
In one 100,000-connection run using Node.js v26.10.0 / libuv v1.52.1:
upstream:
Address: 3,268
Undefined: 96,732
patched:
Address: 100,000
Undefined: 0
The exact percentage is timing-dependent. The relevant observation is that the connections completed and were accepted, but their peer addresses could become unavailable before the later query. Preserving the sockaddr returned at accept time eliminated that failure mode in this test.
Historical context
I documented the relevant Node.js and libuv history here: https://github.com/bobnil/libuv-accept-peer-address-poc/blob/main/HISTORICAL_CONTEXT.md
Before the libuv backend, Node's networking code retained the peer address returned by the accept operation.
The first public Node.js/libuv backend retained the accepted fd but did not propagate the accept-time peer address with it. The address was subsequently obtained through a separate getpeername() query.
Node initially performed that query eagerly. In 2011, it was made lazy, avoiding an unconditional syscall for
applications that never used the remote address and improving the connection-heavy http_simple benchmark by about 1%.
In 2012, libuv also stopped requesting the sockaddr from accept(), since it was not being retained anyway.
Each of these decisions makes sense in isolation. Together, however, they mean that a successfully accepted connection does not necessarily retain the peer information that was available at accept time.
The same underlying race appears to have been identified in nodejs/node-v0.x-archive#7566 in 2014, where retaining the
address returned by accept() was already discussed as a possible solution.
The 2018 report in #23858 is particularly relevant because the remote properties were accessed directly inside the
server's connection handler: the information could already be unavailable at the first application-visible opportunity to read it.
Later Node.js changes improved caching after a successful peer-address lookup. That helps preserve information already obtained, but does not address the case where the socket state changes before the first lookup succeeds.
Performance
I built a proof-of-concept libuv patch that retrieves and preserves the accept-time sockaddr, and benchmarked it against the same unmodified libuv revision: https://github.com/bobnil/libuv-accept-peer-address-poc/blob/main/BENCHMARK.md
The benchmark deliberately favors upstream: the server accepts and immediately closes each connection without querying the peer address. Upstream therefore performs neither accept-time address retrieval nor a later getpeername(), while the patched build retrieves and retains an address that the application never uses.
Tests included local loopback and two-machine runs, ranging from 500,000 to 50 million connections.
Across the datasets, the direction of the measured difference changed: some favored upstream and some favored the patched build, with run-to-run variation larger than the observed differences.
I therefore could not identify a reproducible connection-throughput regression from retrieving and preserving the accept-time sockaddr in this Linux environment.
This does not establish zero cost on every platform or workload. It does suggest that this approach merits evaluation separately from the historical cost of an unconditional getpeername() call: asking accept() to return the peer address does not add that extra syscall.
Where to go from here
The implementation question belongs primarily in libuv, which is why I opened the issue there.
A production-quality solution needs somewhere to retain the peer sockaddr together with the pending accepted connection while respecting libuv's API/ABI compatibility requirements. My patch changes internal storage and is intended as a proof of concept, rather than a proposed final implementation.
I'm raising it here because the behavior is directly visible to Node.js applications. A server can successfully accept a TCP connection yet be unable to determine its remote endpoint even in the connection callback.
That can matter for connection logging, auditing, diagnostics, and IP-based abuse mitigation.
I'd be interested in the Node.js networking/libuv maintainers' thoughts on two questions:
- Should an accepted TCP connection ideally retain the peer address that was available when it was accepted, rather than depending entirely on a later socket-state query?
- If so, what would be the best way for Node.js and libuv to coordinate on preserving that information without disrupting libuv 1.x ABI compatibility?
The libuv issue contains the reproducer, proof of concept, kernel references, and more detailed analysis.
Thanks for taking a look.
Disclaimer
I used AI tools alongside other search tools to help investigate the history, organize the source material, and draft this report in technical English, which is not my native language.
I personally checked the cited sources, reviewed and edited the text. I also ran the reproducer and benchmarks myself. I stand behind the findings and take responsibility for the contents of this report.
I'd like to revisit a long-standing
net.Socketbehavior, following an investigation and proof of concept in libuv: libuv/libuv#5308This is closely related to nodejs/node#23858, opened in 2018, where
socket.remoteAddress,remotePort, andremoteFamilycould already beundefinedinside the server's connection handler.That discussion ended without a fix, but a maintainer comment also left room for reconsideration if a stronger technical case could be made.
I've tried to approach this follow-up in that spirit: I traced the behavior through the Node.js and libuv history, examined the relevant Linux kernel semantics, built a libuv proof of concept that preserves the accept-time peer address, and benchmarked its connection-throughput cost.
The findings point to an information-lifetime issue at the libuv layer. Preserving the accept-time address also has a different performance profile from restoring the old unconditional
getpeername()call.What happens
On Unix, libuv accepts incoming TCP connections without requesting the peer sockaddr from
accept()/accept4(). When Node.js later needs the remote address, libuv callsgetpeername()on the accepted socket.Normally that works. However, a connection can complete successfully and then be reset before the application processes it. The distinction between obtaining the peer address as part of
accept()and querying it later withgetpeername()is part of the original BSD sockets API design, introduced with 4.2BSD in 1983; it is not specific to Linux. The Linux reproducer demonstrates why this distinction matters:accept()can still return the peer address even when a subsequentgetpeername()fails withENOTCONN.Once the accept-time sockaddr has been discarded, the later query cannot recover it.
A reproducer in the libuv issue demonstrates this using a normal TCP
connect()followed by an abortive close withSO_LINGER.In one 100,000-connection run using Node.js v26.10.0 / libuv v1.52.1:
The exact percentage is timing-dependent. The relevant observation is that the connections completed and were accepted, but their peer addresses could become unavailable before the later query. Preserving the sockaddr returned at accept time eliminated that failure mode in this test.
Historical context
I documented the relevant Node.js and libuv history here: https://github.com/bobnil/libuv-accept-peer-address-poc/blob/main/HISTORICAL_CONTEXT.md
Before the libuv backend, Node's networking code retained the peer address returned by the accept operation.
The first public Node.js/libuv backend retained the accepted fd but did not propagate the accept-time peer address with it. The address was subsequently obtained through a separate
getpeername()query.Node initially performed that query eagerly. In 2011, it was made lazy, avoiding an unconditional syscall for
applications that never used the remote address and improving the connection-heavy
http_simplebenchmark by about 1%.In 2012, libuv also stopped requesting the sockaddr from
accept(), since it was not being retained anyway.Each of these decisions makes sense in isolation. Together, however, they mean that a successfully accepted connection does not necessarily retain the peer information that was available at accept time.
The same underlying race appears to have been identified in nodejs/node-v0.x-archive#7566 in 2014, where retaining the
address returned by
accept()was already discussed as a possible solution.The 2018 report in #23858 is particularly relevant because the remote properties were accessed directly inside the
server's connection handler: the information could already be unavailable at the first application-visible opportunity to read it.
Later Node.js changes improved caching after a successful peer-address lookup. That helps preserve information already obtained, but does not address the case where the socket state changes before the first lookup succeeds.
Performance
I built a proof-of-concept libuv patch that retrieves and preserves the accept-time sockaddr, and benchmarked it against the same unmodified libuv revision: https://github.com/bobnil/libuv-accept-peer-address-poc/blob/main/BENCHMARK.md
The benchmark deliberately favors upstream: the server accepts and immediately closes each connection without querying the peer address. Upstream therefore performs neither accept-time address retrieval nor a later
getpeername(), while the patched build retrieves and retains an address that the application never uses.Tests included local loopback and two-machine runs, ranging from 500,000 to 50 million connections.
Across the datasets, the direction of the measured difference changed: some favored upstream and some favored the patched build, with run-to-run variation larger than the observed differences.
I therefore could not identify a reproducible connection-throughput regression from retrieving and preserving the accept-time sockaddr in this Linux environment.
This does not establish zero cost on every platform or workload. It does suggest that this approach merits evaluation separately from the historical cost of an unconditional
getpeername()call: askingaccept()to return the peer address does not add that extra syscall.Where to go from here
The implementation question belongs primarily in libuv, which is why I opened the issue there.
A production-quality solution needs somewhere to retain the peer sockaddr together with the pending accepted connection while respecting libuv's API/ABI compatibility requirements. My patch changes internal storage and is intended as a proof of concept, rather than a proposed final implementation.
I'm raising it here because the behavior is directly visible to Node.js applications. A server can successfully accept a TCP connection yet be unable to determine its remote endpoint even in the connection callback.
That can matter for connection logging, auditing, diagnostics, and IP-based abuse mitigation.
I'd be interested in the Node.js networking/libuv maintainers' thoughts on two questions:
The libuv issue contains the reproducer, proof of concept, kernel references, and more detailed analysis.
Thanks for taking a look.
Disclaimer
I used AI tools alongside other search tools to help investigate the history, organize the source material, and draft this report in technical English, which is not my native language.
I personally checked the cited sources, reviewed and edited the text. I also ran the reproducer and benchmarks myself. I stand behind the findings and take responsibility for the contents of this report.