Skip to main content

Network Programming in Go: TCP, UDP, and Unix Sockets

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

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.SetDeadline before each read/write in long-running handlers — prevents connections from being held indefinitely
  • Use io.ReadFull for framing — partial reads are common on TCP, Read may 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.DialContext respects context cancellation and enforces connection timeouts

Resources

Comments

👍 Was this article helpful?