Sockets #

Socket programming in Go is built on the net package, which abstracts TCP, UDP, Unix domain sockets, and other network protocols through a consistent interface. What makes Go so well-suited for networking is goroutines — you can handle thousands of concurrent connections with a simple pattern: one goroutine per connection. No callback hell, no manual event loops, just readable sequential code.

The TCP Socket connection lifecycle between a Server and a Client, and their interaction through method calls from the net package, can be visualized in the following diagram:

flowchart TD
    subgraph ServerCycle["TCP Server Cycle"]
        Listen["net.Listen('tcp', addr)"] --> Accept["Listener.Accept()"]
        Accept -->|"Connection Established (Blocking/Wait)"| ConnHandler["Goroutine: handleConn(net.Conn)"]
        ConnHandler --> ReadWriteS["Read() / Write()"]
        ReadWriteS --> CloseS["net.Conn.Close()"]
    end

    subgraph ClientCycle["TCP Client Cycle"]
        Connect["net.Dial('tcp', addr)"] --> ReadWriteC["Read() / Write()"]
        ReadWriteC --> CloseC["net.Conn.Close()"]
    end

    Connect -->|"Connection Request"| Accept
    ReadWriteC <-->|"Data Exchange"| ReadWriteS

TCP — Transmission Control Protocol #

TCP guarantees delivery order and data reliability. This is what you use for HTTP, databases, and almost any protocol that needs reliability.

TCP Server #

The basic TCP server pattern: listen → accept → handle per goroutine:

import (
    "bufio"
    "fmt"
    "net"
    "log"
)

func handleConn(conn net.Conn) {
    defer conn.Close()  // make sure the connection is always closed

    addr := conn.RemoteAddr().String()
    log.Printf("New connection from: %s", addr)

    scanner := bufio.NewScanner(conn)
    for scanner.Scan() {
        msg := scanner.Text()
        log.Printf("[%s] → %s", addr, msg)

        // Echo back to the client
        fmt.Fprintf(conn, "Echo: %s\n", msg)
    }

    if err := scanner.Err(); err != nil {
        log.Printf("[%s] error: %v", addr, err)
    }
    log.Printf("Connection closed: %s", addr)
}

func main() {
    ln, err := net.Listen("tcp", ":8080")
    if err != nil {
        log.Fatal("Failed to listen:", err)
    }
    defer ln.Close()

    log.Println("TCP server listening on :8080")

    for {
        conn, err := ln.Accept()
        if err != nil {
            log.Println("Accept error:", err)
            continue
        }
        go handleConn(conn)  // one goroutine per connection
    }
}

Graceful Shutdown #

A server that can stop cleanly — waiting for active connections to finish before exiting:

import (
    "context"
    "net"
    "sync"
    "log"
    "os/signal"
    "syscall"
    "os"
)

type Server struct {
    ln      net.Listener
    wg      sync.WaitGroup
    quit    chan struct{}
}

func NewServer(addr string) (*Server, error) {
    ln, err := net.Listen("tcp", addr)
    if err != nil {
        return nil, err
    }
    return &Server{ln: ln, quit: make(chan struct{})}, nil
}

func (s *Server) Start() {
    s.wg.Add(1)
    go func() {
        defer s.wg.Done()
        for {
            conn, err := s.ln.Accept()
            if err != nil {
                select {
                case <-s.quit:
                    return  // the server is shutting down, not an error
                default:
                    log.Println("Accept error:", err)
                    continue
                }
            }
            s.wg.Add(1)
            go func() {
                defer s.wg.Done()
                handleConn(conn)
            }()
        }
    }()
}

func (s *Server) Stop() {
    close(s.quit)      // signal shutdown
    s.ln.Close()       // force Accept() to return an error
    s.wg.Wait()        // wait for all goroutines to finish
    log.Println("Server stopped cleanly")
}

func main() {
    srv, err := NewServer(":8080")
    if err != nil {
        log.Fatal(err)
    }
    srv.Start()
    log.Println("Server running on :8080")

    // Wait for an OS signal (Ctrl+C or SIGTERM)
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
    <-sigCh

    log.Println("Received shutdown signal...")
    srv.Stop()
}

TCP Client #

import (
    "bufio"
    "fmt"
    "net"
    "time"
)

func main() {
    // Basic connection
    conn, err := net.Dial("tcp", "localhost:8080")
    if err != nil {
        log.Fatal("Failed to connect:", err)
    }
    defer conn.Close()

    // With a connection timeout
    conn2, err := net.DialTimeout("tcp", "localhost:8080", 5*time.Second)
    if err != nil {
        log.Fatal("Connection timeout:", err)
    }
    defer conn2.Close()

    // Send a message
    fmt.Fprintf(conn, "Hello server!\n")

    // Receive a response
    reader := bufio.NewReader(conn)
    resp, err := reader.ReadString('\n')
    if err != nil {
        log.Fatal("Read error:", err)
    }
    fmt.Print("Server:", resp)
}

Deadlines — Timeouts on Connections #

Without a deadline, Read/Write operations can block forever if the client doesn’t send or receive data. Always set deadlines for production connections:

func handleConn(conn net.Conn) {
    defer conn.Close()

    // Set a deadline for the whole connection (from now)
    conn.SetDeadline(time.Now().Add(30 * time.Second))

    // Or set per-operation:
    // Read deadline — how long to wait for incoming data
    conn.SetReadDeadline(time.Now().Add(10 * time.Second))
    // Write deadline — how long to wait for a write to finish
    conn.SetWriteDeadline(time.Now().Add(5 * time.Second))

    scanner := bufio.NewScanner(conn)
    for scanner.Scan() {
        // Reset the read deadline after receiving data
        conn.SetReadDeadline(time.Now().Add(10 * time.Second))

        msg := scanner.Text()
        fmt.Fprintf(conn, "OK: %s\n", msg)
    }

    if err := scanner.Err(); err != nil {
        if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
            log.Println("Connection timeout")
        }
    }
}

Protocol Design — Reading Data Correctly #

TCP is a stream of bytes — there’s no built-in “message” boundary. You need your own protocol to determine where one message ends and the next begins.

Delimiter-Based (Newline Protocol) #

// Good for simple text — separate messages with '\n'
// Weakness: messages can't contain newlines

// Send
fmt.Fprintf(conn, "message without a newline in the middle\n")

// Receive
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
    message := scanner.Text()  // without '\n'
    process(message)
}

Length-Prefix Protocol #

import "encoding/binary"

// More robust — send the message length (4 bytes) followed by the message contents
// Supports binary messages and messages with newlines inside

// Send
func sendMessage(conn net.Conn, msg []byte) error {
    // Write the message length as a uint32 big-endian (4 bytes)
    length := uint32(len(msg))
    if err := binary.Write(conn, binary.BigEndian, length); err != nil {
        return fmt.Errorf("send length: %w", err)
    }
    // Write the message contents
    _, err := conn.Write(msg)
    return err
}

// Receive
func receiveMessage(conn net.Conn) ([]byte, error) {
    // Read the first 4 bytes to get the length
    var length uint32
    if err := binary.Read(conn, binary.BigEndian, &length); err != nil {
        return nil, fmt.Errorf("read length: %w", err)
    }

    // Validate — prevent giant memory allocations from a malicious client
    if length > 10*1024*1024 {  // max 10MB
        return nil, fmt.Errorf("message too large: %d bytes", length)
    }

    // Read exactly the required number of bytes
    msg := make([]byte, length)
    if _, err := io.ReadFull(conn, msg); err != nil {
        return nil, fmt.Errorf("read message: %w", err)
    }
    return msg, nil
}
io.ReadFull is essential for length-prefix protocols. A regular conn.Read() doesn’t guarantee reading the requested number of bytes — it may return fewer. io.ReadFull keeps reading until the buffer is full or an error occurs.

UDP — User Datagram Protocol #

UDP has no connection, no guarantee of order or delivery. Good for: realtime games, live streaming, DNS, DHCP — situations where speed matters more than reliability.

UDP Server #

func main() {
    addr, _ := net.ResolveUDPAddr("udp", ":9090")
    conn, err := net.ListenUDP("udp", addr)
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()
    log.Println("UDP server on :9090")

    buf := make([]byte, 1024)
    for {
        n, remoteAddr, err := conn.ReadFromUDP(buf)
        if err != nil {
            log.Println("Error:", err)
            continue
        }

        msg := string(buf[:n])
        log.Printf("From %s: %s", remoteAddr, msg)

        // Send a reply
        reply := fmt.Sprintf("Echo: %s", msg)
        conn.WriteToUDP([]byte(reply), remoteAddr)
    }
}

UDP Client #

func main() {
    serverAddr, _ := net.ResolveUDPAddr("udp", "localhost:9090")
    conn, err := net.DialUDP("udp", nil, serverAddr)
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()

    // Send without waiting for a connection
    conn.Write([]byte("Hello UDP!"))

    // Read the response (with a timeout because UDP can be lossy)
    conn.SetReadDeadline(time.Now().Add(2 * time.Second))
    buf := make([]byte, 1024)
    n, err := conn.Read(buf)
    if err != nil {
        log.Println("Timeout or error:", err)
        return
    }
    fmt.Println(string(buf[:n]))
}

Unix Domain Sockets #

Unix domain sockets for inter-process communication on the same machine — faster than TCP loopback because they don’t go through the network stack:

// Unix socket server
func main() {
    socketPath := "/tmp/myapp.sock"
    os.Remove(socketPath)  // remove the old socket if it exists

    ln, err := net.Listen("unix", socketPath)
    if err != nil {
        log.Fatal(err)
    }
    defer ln.Close()
    defer os.Remove(socketPath)

    log.Println("Unix socket server:", socketPath)

    for {
        conn, err := ln.Accept()
        if err != nil {
            log.Println("Accept error:", err)
            continue
        }
        go handleConn(conn)
    }
}

// Unix socket client
func connectUnix() {
    conn, err := net.Dial("unix", "/tmp/myapp.sock")
    if err != nil {
        log.Fatal("Failed to connect to unix socket:", err)
    }
    defer conn.Close()

    fmt.Fprintf(conn, "Hello via unix socket!\n")
}

TLS — Connection Encryption #

For production, all connections must be encrypted with TLS:

import "crypto/tls"

// TLS Server
func tlsServer() {
    // Load the certificate and private key
    cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
    if err != nil {
        log.Fatal("Failed to load certificate:", err)
    }

    config := &tls.Config{
        Certificates: []tls.Certificate{cert},
        MinVersion:   tls.VersionTLS13,  // use TLS 1.3 minimum
    }

    ln, err := tls.Listen("tcp", ":8443", config)
    if err != nil {
        log.Fatal("Failed to listen TLS:", err)
    }
    defer ln.Close()

    log.Println("TLS server on :8443")
    for {
        conn, err := ln.Accept()
        if err != nil {
            continue
        }
        go handleConn(conn)  // conn is a tls.Conn, but implements net.Conn
    }
}

// TLS Client
func tlsClient() {
    config := &tls.Config{
        InsecureSkipVerify: false,  // DON'T set to true in production!
        MinVersion:         tls.VersionTLS13,
    }

    conn, err := tls.Dial("tcp", "localhost:8443", config)
    if err != nil {
        log.Fatal("Failed to connect TLS:", err)
    }
    defer conn.Close()

    fmt.Fprintf(conn, "Secret message!\n")
}

Complete Example Program — Multi-Client Chat Server #

The following program builds a complete chat server with broadcast to all clients:

package main

import (
    "bufio"
    "fmt"
    "log"
    "net"
    "strings"
    "sync"
    "time"
)

// Message represents a chat message
type Message struct {
    From    string
    Content string
    Time    time.Time
}

func (m Message) String() string {
    return fmt.Sprintf("[%s] %s: %s",
        m.Time.Format("15:04:05"), m.From, m.Content)
}

// Hub manages all connected clients
type Hub struct {
    mu      sync.RWMutex
    clients map[string]net.Conn   // username → connection
    msgCh   chan Message
}

func NewHub() *Hub {
    h := &Hub{
        clients: make(map[string]net.Conn),
        msgCh:   make(chan Message, 256),
    }
    go h.broadcastLoop()
    return h
}

func (h *Hub) Register(username string, conn net.Conn) bool {
    h.mu.Lock()
    defer h.mu.Unlock()
    if _, exists := h.clients[username]; exists {
        return false  // the username is already taken
    }
    h.clients[username] = conn
    return true
}

func (h *Hub) Unregister(username string) {
    h.mu.Lock()
    defer h.mu.Unlock()
    delete(h.clients, username)
}

func (h *Hub) Broadcast(msg Message) {
    h.msgCh <- msg
}

func (h *Hub) broadcastLoop() {
    for msg := range h.msgCh {
        text := msg.String() + "\n"
        h.mu.RLock()
        for username, conn := range h.clients {
            if username == msg.From {
                continue  // don't send to the sender themselves
            }
            conn.SetWriteDeadline(time.Now().Add(3 * time.Second))
            if _, err := fmt.Fprint(conn, text); err != nil {
                log.Printf("Failed to send to %s: %v", username, err)
            }
        }
        h.mu.RUnlock()
    }
}

func (h *Hub) UserList() []string {
    h.mu.RLock()
    defer h.mu.RUnlock()
    users := make([]string, 0, len(h.clients))
    for u := range h.clients {
        users = append(users, u)
    }
    return users
}

// handleClient handles a single client
func handleClient(conn net.Conn, hub *Hub) {
    defer conn.Close()
    remote := conn.RemoteAddr().String()

    // Ask for a username
    fmt.Fprint(conn, "Enter your username: ")
    conn.SetReadDeadline(time.Now().Add(30 * time.Second))

    reader := bufio.NewReader(conn)
    username, err := reader.ReadString('\n')
    if err != nil {
        log.Printf("[%s] Failed to read username: %v", remote, err)
        return
    }
    username = strings.TrimSpace(username)
    if username == "" || len(username) > 20 {
        fmt.Fprint(conn, "Invalid username. Connection closed.\n")
        return
    }

    // Register with the hub
    if !hub.Register(username, conn) {
        fmt.Fprintf(conn, "The username '%s' is already taken. Try again.\n", username)
        return
    }
    defer hub.Unregister(username)

    // Welcome the new user
    fmt.Fprintf(conn, "Welcome, %s! Active users: %s\n",
        username, strings.Join(hub.UserList(), ", "))

    // Announce to everyone
    hub.Broadcast(Message{
        From:    "SYSTEM",
        Content: fmt.Sprintf("%s joined the chat", username),
        Time:    time.Now(),
    })

    log.Printf("%s connected from %s", username, remote)

    // Loop reading messages from this client
    for {
        conn.SetReadDeadline(time.Now().Add(5 * time.Minute))

        line, err := reader.ReadString('\n')
        if err != nil {
            break
        }

        text := strings.TrimSpace(line)
        if text == "" {
            continue
        }

        // Special commands
        switch {
        case text == "/quit":
            fmt.Fprint(conn, "Goodbye!\n")
            goto done

        case text == "/users":
            users := hub.UserList()
            fmt.Fprintf(conn, "Active users (%d): %s\n",
                len(users), strings.Join(users, ", "))

        case strings.HasPrefix(text, "/whisper "):
            // Private message: /whisper username message
            parts := strings.SplitN(text[9:], " ", 2)
            if len(parts) != 2 {
                fmt.Fprint(conn, "Format: /whisper <username> <message>\n")
                continue
            }
            target, msg := parts[0], parts[1]

            hub.mu.RLock()
            targetConn, exists := hub.clients[target]
            hub.mu.RUnlock()

            if !exists {
                fmt.Fprintf(conn, "User '%s' not found\n", target)
                continue
            }
            fmt.Fprintf(targetConn, "[PRIVATE from %s]: %s\n", username, msg)
            fmt.Fprintf(conn, "[PRIVATE to %s]: %s\n", target, msg)

        default:
            // Broadcast to everyone
            hub.Broadcast(Message{
                From:    username,
                Content: text,
                Time:    time.Now(),
            })
        }
    }

done:
    hub.Broadcast(Message{
        From:    "SYSTEM",
        Content: fmt.Sprintf("%s left the chat", username),
        Time:    time.Now(),
    })
    log.Printf("%s disconnected", username)
}

func main() {
    hub := NewHub()

    ln, err := net.Listen("tcp", ":8080")
    if err != nil {
        log.Fatal("Failed to listen:", err)
    }
    defer ln.Close()

    log.Println("Chat server running on :8080")
    log.Println("Connect with: nc localhost 8080")

    for {
        conn, err := ln.Accept()
        if err != nil {
            log.Println("Accept error:", err)
            continue
        }
        go handleClient(conn, hub)
    }
}

Summary #

  • net.Listen + ln.Accept + go handleConn is the basic TCP server pattern in Go — one goroutine per connection.
  • Always defer conn.Close() at the start of a handler to guarantee the connection is closed even on panic.
  • Graceful shutdown: close the listener to make Accept() return an error, then wait for all goroutines with a WaitGroup.
  • TCP is a stream — no built-in message boundaries; use a delimiter (\n) or a length-prefix protocol.
  • io.ReadFull for reading exactly N bytes — a regular conn.Read() can return fewer than requested.
  • Always set deadlines (SetReadDeadline, SetWriteDeadline) to prevent goroutine leaks from hung connections.
  • UDP for fast communication without delivery guarantees — games, DNS, live streaming.
  • Unix domain sockets are faster than TCP loopback for inter-process communication on the same machine.
  • TLS is mandatory for production — use tls.Listen and tls.Dial, set MinVersion: tls.VersionTLS13.
  • sync.RWMutex protects shared state (the client list) — RLock for concurrent reads, Lock for exclusive writes.

← Previous: I/O   Next: WebSocket →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact