Go’s net package provides a uniform interface for all network I/O: TCP, UDP, Unix domain sockets, and IP connections all implement net.Conn (or net.PacketConn for datagram protocols). This uniformity means the patterns are the same across protocols.
For most web services, net/http is the right layer. Reach for the raw net package when you need a custom protocol, WebSocket-like persistent connections, UDP for real-time data, or Unix sockets for local IPC.
TCP Server
A production TCP server needs four things: accept connections, handle each in a goroutine, set deadlines to prevent slow clients from holding connections forever, and stop cleanly on signal:
type TCPServer struct {
addr string
handler func(net.Conn)
}
func (s *TCPServer) ListenAndServe(ctx context.Context) error {
ln, err := net.Listen("tcp", s.addr)
if err != nil {
return fmt.Errorf("listen %s: %w", s.addr, err)
}
// Close listener when context is done
go func() {
<-ctx.Done()
ln.Close()
}()
log.Printf("listening on %s", s.addr)
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return nil // context cancelled — clean shutdown
}
return fmt.Errorf("accept: %w", err)
}
go s.handleConn(conn)
}
}
func (s *TCPServer) handleConn(conn net.Conn) {
defer conn.Close()
// Apply a read deadline — prevents slow/idle clients from leaking connections
conn.SetDeadline(time.Now().Add(30 * time.Second))
if s.handler != nil {
s.handler(conn)
return
}
// Default: echo server
buf := make([]byte, 4096)
for {
// Reset deadline on each successful read
conn.SetDeadline(time.Now().Add(30 * time.Second))
n, err := conn.Read(buf)
if err != nil {
if err != io.EOF && !isTimeout(err) {
log.Printf("read error: %v", err)
}
return
}
if _, err := conn.Write(buf[:n]); err != nil {
return
}
}
}
func isTimeout(err error) bool {
var netErr net.Error
return errors.As(err, &netErr) && netErr.Timeout()
}
Calling conn.SetDeadline before each Read (not just once at connection start) is the right pattern for protocols where you want to allow idle connections for a window of time after each message, but not indefinitely.
TCP Client with Dialer
For outbound connections, net.Dialer gives you control over connection timeouts and keep-alive:
dialer := &net.Dialer{
Timeout: 5 * time.Second, // max time to establish connection
KeepAlive: 30 * time.Second, // TCP keep-alive interval
}
// Dial with context — respects cancellation
conn, err := dialer.DialContext(ctx, "tcp", "backend.internal:9000")
if err != nil {
return fmt.Errorf("connecting to backend: %w", err)
}
defer conn.Close()
// Set per-operation deadlines
conn.SetDeadline(time.Now().Add(10 * time.Second))
// Send a request
enc := json.NewEncoder(conn)
if err := enc.Encode(request); err != nil {
return err
}
// Read response
conn.SetDeadline(time.Now().Add(10 * time.Second))
var resp Response
return json.NewDecoder(conn).Decode(&resp)
For connection pools to the same backend, use net.Dialer once and reuse connections. Creating a new connection per request is expensive — TCP handshake alone is typically 1–3 RTTs.
Framing: The Protocol Within the Connection
Raw TCP is a byte stream — there are no message boundaries. You must implement framing to know where one message ends and the next begins. Common approaches:
Length-prefix framing (most common for binary protocols):
// Write: 4-byte big-endian length prefix, then payload
func writeFrame(conn net.Conn, data []byte) error {
header := make([]byte, 4)
binary.BigEndian.PutUint32(header, uint32(len(data)))
if _, err := conn.Write(header); err != nil {
return err
}
_, err := conn.Write(data)
return err
}
// Read: read 4-byte length, then exactly that many bytes
func readFrame(conn net.Conn) ([]byte, error) {
header := make([]byte, 4)
if _, err := io.ReadFull(conn, header); err != nil {
return nil, err
}
length := binary.BigEndian.Uint32(header)
if length > 10<<20 { // sanity check: max 10 MB
return nil, fmt.Errorf("frame too large: %d bytes", length)
}
payload := make([]byte, length)
_, err := io.ReadFull(conn, payload)
return payload, err
}
io.ReadFull reads exactly len(buf) bytes, retrying partial reads — critical for reliable framing.
Line-delimited text (for human-readable protocols):
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 0, 64*1024), 1<<20) // up to 1 MB lines
for scanner.Scan() {
handleLine(scanner.Text())
}
UDP: Stateless, Low-Latency
UDP sends discrete datagrams — no connection, no ordering, no delivery guarantee. Right for: DNS, metrics, game state, anything where a dropped packet is acceptable and low latency matters more than reliability.
// UDP server
addr, _ := net.ResolveUDPAddr("udp", ":9999")
conn, err := net.ListenUDP("udp", addr)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
buf := make([]byte, 1500) // one Ethernet MTU
for {
n, remoteAddr, err := conn.ReadFromUDP(buf)
if err != nil {
if ctx.Err() != nil { return }
log.Printf("read error: %v", err)
continue
}
// Handle concurrently — UDP has no connection state
go handleDatagram(buf[:n], remoteAddr, conn)
}
func handleDatagram(data []byte, addr *net.UDPAddr, conn *net.UDPConn) {
// Respond to the sender
conn.WriteToUDP(append([]byte("echo: "), data...), addr)
}
// UDP client
conn, err := net.Dial("udp", "server:9999")
// Dial on UDP just sets the default remote address — still connectionless
defer conn.Close()
conn.SetWriteDeadline(time.Now().Add(time.Second))
conn.Write([]byte("ping"))
conn.SetReadDeadline(time.Now().Add(time.Second))
buf := make([]byte, 1500)
n, err := conn.Read(buf)
UDP datagrams larger than the path MTU (~1400 bytes) get fragmented at the IP level and reassembled at the destination. Fragmentation increases loss probability — keep datagrams under 1400 bytes or handle fragmentation at the application level.
Unix Domain Sockets: Fast Local IPC
Unix domain sockets work like TCP sockets but through the filesystem. They’re faster (no network stack overhead) and support file descriptor passing between processes:
// Server
socketPath := "/tmp/myapp.sock"
os.Remove(socketPath) // remove stale socket from previous run
ln, err := net.Listen("unix", socketPath)
if err != nil {
log.Fatal(err)
}
defer os.Remove(socketPath) // cleanup on exit
defer ln.Close()
// Set permissions so only the owning user can connect
os.Chmod(socketPath, 0700)
for {
conn, err := ln.Accept()
if err != nil { break }
go handleConn(conn)
}
// Client
conn, err := net.Dial("unix", "/tmp/myapp.sock")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
Unix sockets are the right choice for communication between processes on the same host — Docker daemon, systemd, databases (PostgreSQL can connect via Unix socket), and sidecar patterns.
Checking Connection State
A TCP connection can appear open while the remote end has gone away (network partition, process crash). Detecting this requires reading — write can succeed if data is still in the send buffer:
// A zero-length read returns immediately — if the connection is closed,
// it returns io.EOF (or an error), not a successful 0-byte read
func isConnAlive(conn net.Conn) bool {
conn.SetReadDeadline(time.Now())
defer conn.SetReadDeadline(time.Time{}) // clear deadline
buf := make([]byte, 1)
_, err := conn.Read(buf)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
return true // timeout = still alive, just no data
}
return false // io.EOF or other error = closed
}
return true
}
For connection pools, ping the connection before returning it for reuse, or use application-level heartbeats.
Summary
- Set
conn.SetDeadlinebefore each read/write in long-running handlers — prevents connections from being held indefinitely - Use
io.ReadFullfor framing — partial reads are common on TCP,Readmay return less than requested - Length-prefix framing (4-byte header + payload) is the most common pattern for binary protocols
- UDP is connectionless and unordered — right for metrics, DNS, game state; keep datagrams under 1400 bytes
- Unix domain sockets for local IPC — faster than TCP, supports file descriptor passing
net.Dialer.DialContextrespects context cancellation and enforces connection timeouts
Resources
- net package documentation
- Go by Example: TCP Servers
- io.ReadFull
- Beej’s Guide to Network Programming
Comments