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"| ReadWriteSTCP — 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.ReadFullis essential for length-prefix protocols. A regularconn.Read()doesn’t guarantee reading the requested number of bytes — it may return fewer.io.ReadFullkeeps 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 handleConnis 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 aWaitGroup.- TCP is a stream — no built-in message boundaries; use a delimiter (
\n) or a length-prefix protocol.io.ReadFullfor reading exactly N bytes — a regularconn.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.Listenandtls.Dial, setMinVersion: tls.VersionTLS13.sync.RWMutexprotects shared state (the client list) — RLock for concurrent reads, Lock for exclusive writes.