Network Programming in Go¶
Below net/http¶
Most Go services never touch a raw socket, and most performance and
reliability problems in Go services are still socket problems: missing
deadlines, unbounded per-connection memory, a write per small message,
connections never reused, slow clients holding resources. This page
goes one level below net/http — how the netpoller makes blocking-style
code scale, how to write a TCP server that survives real clients, and
which socket options matter. The protocol background is in
TCP and
I/O Models and Event Loop.
Measurements: Go 1.26.4, Apple M1 Pro, loopback.
The netpoller: blocking code, non-blocking sockets¶
Go code reads a socket as if it blocked:
n, err := conn.Read(buf) // looks blocking
Underneath, every socket is in non-blocking mode and registered with the OS readiness API — epoll on Linux (edge-triggered), kqueue on macOS/BSD, IOCP on Windows:
This is the I/O model of an event loop (one epoll set per process, readiness-based), presented as goroutines with straight-line code. The cost is per-goroutine memory instead of callbacks; the benefit is that every connection's logic is ordinary sequential code with ordinary stack traces. It is why goroutine-per-connection is the default design in Go, and why it scales to hundreds of thousands of connections — as long as per-connection memory stays small.
What one connection costs¶
5,000 idle TCP connections to a simple echo server (io.Copy(conn, conn)
per connection), client and server in the same process, measured:
| Total | Per connection | |
|---|---|---|
| Goroutine stacks | +20 MB | ~4 KB per goroutine |
| Heap | +165 MB | ~33 KB |
The heap number is the surprise: io.Copy allocates a 32 KB buffer
for every call, and each connection's goroutine holds one for its
whole life, idle or not. At 100,000 connections that is 3.2 GB of
buffers for connections that are mostly silent. Remedies:
- Use smaller buffers (
io.CopyBufferwith a 4 KB buffer, or abufio.Readerof a deliberate size) for chatty, low-volume protocols. - Allocate buffers only while a request is in flight and return them
to a
sync.Poolbetween requests. - For extreme connection counts (millions), event-loop libraries
(
gnet,cloudwego/netpoll) avoid the goroutine-and-buffer per connection, at the cost of callback-style code.
Kernel memory per connection (socket buffers, often 4–128 KB each
direction with autotuning) comes on top and is visible in ss -tm,
not in Go's metrics.
Batching writes: the biggest easy win¶
Sending 200,000 messages of 16 bytes over loopback:
| Writer | Total | Per message |
|---|---|---|
conn.Write(msg) per message |
1.158 s | 5,791 ns |
bufio.NewWriterSize(conn, 64<<10) + Flush at the end |
2 ms | 8 ns |
Every conn.Write is a system call plus a TCP segment. bufio
coalesces the messages into 64 KB writes: 700x faster. The design
question is when to flush: after each logical response (simple,
correct), or when no more responses are queued (lower overhead under
load — see the server in Designing a Binary Protocol).
Forgetting to Flush is the classic bug: the last response sits in the
buffer and the client waits forever.
On the read side, wrap the connection in a bufio.Reader so parsing
small headers does not cost one syscall per field.
Nagle's algorithm and TCP_NODELAY¶
Nagle's algorithm holds back small segments while earlier data is unacknowledged. Combined with the receiver's delayed ACK (up to 40 ms on Linux), a write-write-read pattern — header and body sent as two writes, then waiting for the reply — can stall for tens of milliseconds per request on a real network.
Go disables Nagle by default: every *net.TCPConn gets
TCP_NODELAY set when it is created (setNoDelay(fd, true) in
net/tcpsock.go). So in Go you get low latency by default and should
batch with bufio instead of relying on the kernel. Measured
write-write-read over macOS loopback: 28.8 µs per round trip with
NoDelay(true), 41.0 µs with SetNoDelay(false) — slower, but
without the 40 ms stall, which depends on the peer's delayed-ACK
behaviour and does not reproduce on this loopback. Re-enable Nagle only
for bulk transfers made of many tiny writes you cannot batch.
A TCP server that survives real clients¶
func serve(ctx context.Context, ln net.Listener, handle func(context.Context, net.Conn) error) error {
var wg sync.WaitGroup
defer wg.Wait() // graceful: wait for connections to finish
sem := make(chan struct{}, 10_000) // cap concurrent connections
go func() { <-ctx.Done(); ln.Close() }() // shutdown: stop accepting
for {
conn, err := ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return nil
}
if errors.Is(err, syscall.EMFILE) || errors.Is(err, syscall.ENFILE) {
time.Sleep(10 * time.Millisecond) // out of fds: back off, don't spin or exit
continue
}
return err
}
select {
case sem <- struct{}{}:
default:
conn.Close() // over capacity: shed load at the door
continue
}
wg.Add(1)
go func() {
defer func() { conn.Close(); <-sem; wg.Done() }()
if err := handle(ctx, conn); err != nil && !errors.Is(err, io.EOF) {
log.Printf("conn %v: %v", conn.RemoteAddr(), err)
}
}()
}
}
And inside handle, every blocking operation has a deadline:
for {
conn.SetReadDeadline(time.Now().Add(idleTimeout)) // idle clients are dropped
req, err := readRequest(r)
if err != nil {
return err
}
conn.SetWriteDeadline(time.Now().Add(writeTimeout)) // slow readers can't pin us
if err := writeResponse(w, handleReq(ctx, req)); err != nil {
return err
}
}
Deadlines are absolute times, not durations, and apply to all
future I/O until changed — reset them per operation. Without a read
deadline, a client that vanishes without a FIN or RST (pulled cable,
NAT entry expired) leaves the goroutine blocked until TCP keepalive
notices — with Go's defaults (15 s idle, 15 s interval, 9 probes), about
2.5 minutes; with keepalive disabled, forever. Without a write deadline,
a client that stops reading fills the socket buffers and the server's
Write blocks forever: a classic way for one bad client to leak
goroutines.
For HTTP, the same rules are http.Server fields: always set
ReadHeaderTimeout (protects against Slowloris-style slow headers),
ReadTimeout/WriteTimeout or per-handler deadlines via
http.ResponseController, and IdleTimeout.
Socket options and kernel limits worth knowing¶
| Knob | What it controls | In Go |
|---|---|---|
| Listen backlog | Queue of completed handshakes waiting for Accept |
Go uses /proc/sys/net/core/somaxconn on Linux. Overflows show as ListenOverflows in nstat — clients see connection timeouts while your server is "fine" |
SO_REUSEPORT |
Several listening sockets on one port; kernel load-balances new connections | net.ListenConfig{Control: ...} + unix.SetsockoptInt(fd, SOL_SOCKET, SO_REUSEPORT, 1) |
SO_RCVBUF/SO_SNDBUF |
Socket buffer sizes; throughput on long fat links needs ≥ bandwidth × RTT (1 Gbit/s × 50 ms ≈ 6 MB) | SetReadBuffer/SetWriteBuffer; usually leave Linux autotuning alone |
| Keepalive | Detect dead peers on idle connections | On by default (15 s / 15 s / 9); tune with net.KeepAliveConfig (Go 1.23) |
SO_LINGER 0 |
Close sends RST and skips TIME_WAIT |
SetLinger(0) — only for aborting broken connections |
| Half-close | Send FIN but keep reading | (*net.TCPConn).CloseWrite() |
| File descriptor limit | Max open sockets + files | ulimit -n; Go raises the soft limit to the hard limit at startup since Go 1.19 |
Ephemeral ports / TIME_WAIT |
Client-side short connections exhaust local ports (~28k by default on Linux) | Reuse connections (pooling, HTTP keep-alive); don't open one per request |
Zero-copy paths¶
io.Copy checks for ReaderFrom/WriterTo. On Linux:
- file → socket uses
sendfile(2): the kernel copies page-cache pages to the socket without a round trip through user space; - socket → socket (
*TCPConn.ReadFromwith a TCP/Unix source) usessplice(2)through a pipe; - file → file uses
copy_file_range(2).
A reverse proxy or file server that passes plain *os.File and
*net.TCPConn values to io.Copy gets these for free; one that wraps
them (bufio, a counting reader, TLS) does not — TLS in particular
must encrypt in user space (except with kernel TLS, which Go does not
use).
Client side¶
- Reuse connections.
http.Client/http.Transportpool them; the pool only works if response bodies are fully read and closed (Goroutine Leaks shows the leak when they are not). SetMaxIdleConnsPerHost— the default of 2 is far too low for a service calling one backend heavily, and causes a storm of new connections (andTIME_WAITsockets) under load. - Timeouts at every layer: dial (
net.Dialer.Timeout), TLS handshake, response headers, and an overallcontextdeadline.http.Client{}with noTimeoutwaits forever. - DNS: the pure-Go resolver (default when cgo is off) is goroutine-friendly; the cgo resolver blocks a thread per lookup. Go does not cache DNS answers itself — a high-rate client dialing new connections hits DNS each time, another reason to pool.
Common pitfall¶
A server without write deadlines and a slow consumer. A streaming
service pushes events to subscribers with conn.Write from one
goroutine per subscriber. One subscriber on a bad mobile network stops
reading; its socket send buffer fills, and the server's Write blocks.
If events are fanned out from a shared loop that writes to each
subscriber in turn, that one client now blocks every other
subscriber. Even with a goroutine per subscriber, each stuck one
holds its buffers forever. The fix has three parts: a write deadline
on every write, a bounded per-subscriber queue between the producer and
the writer goroutine, and a policy for when the queue is full —
disconnect the slow client (and let it resume from an offset) rather
than block or buffer without limit. Backpressure from a slow peer must
end at that peer.
Review questions¶
- Why can goroutine-per-connection scale to 100,000 connections, and what per-connection cost limits it in practice?
- Where did the 33 KB of heap per idle connection come from in the echo-server experiment?
- Go sets
TCP_NODELAYby default. What should you do instead of relying on Nagle to batch small writes? - Why is a read deadline needed even with TCP keepalive enabled, and what does a missing write deadline let a slow client do?
- Which wrapping of
io.Copy's arguments disablessendfile/splice, and why?