Skip to main content

TCP/UDP Socket Programming — net Package

Go's net package provides TCP, UDP, and Unix domain sockets through one consistent API. Combined with goroutines, you can easily build high-performance network servers handling thousands of concurrent connections.

net Package Basics

Core Interfaces and Types

// net.Conn: interface representing a network connection
// serves as io.Reader + io.Writer + io.Closer
type Conn interface {
Read(b []byte) (n int, err error)
Write(b []byte) (n int, err error)
Close() error
LocalAddr() Addr // local address
RemoteAddr() Addr // remote address
SetDeadline(t time.Time) error // read + write deadline
SetReadDeadline(t time.Time) error // read deadline
SetWriteDeadline(t time.Time) error // write deadline
}

// net.Addr: network address interface
type Addr interface {
Network() string // "tcp", "udp", "unix", etc.
String() string // "192.168.1.1:8080" format
}

Basic Connection Patterns

package main

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

func main() {
// basic TCP connection (Dial)
conn, err := net.Dial("tcp", "example.com:80")
if err != nil {
fmt.Println("connection failed:", err)
return
}
defer conn.Close()

fmt.Println("connected")
fmt.Println("local address:", conn.LocalAddr())
fmt.Println("remote address:", conn.RemoteAddr())

// connection with timeout (recommended in production)
conn2, err := net.DialTimeout("tcp", "example.com:80", 5*time.Second)
if err != nil {
fmt.Println("dial timeout failed:", err)
return
}
defer conn2.Close()

// set deadline: read/write timeout
conn2.SetDeadline(time.Now().Add(10 * time.Second))
}

TCP Server — Handling Multiple Clients

package main

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

// handleClient: handle individual client connection (runs as goroutine)
func handleClient(conn net.Conn) {
defer conn.Close()

remoteAddr := conn.RemoteAddr().String()
fmt.Printf("[%s] new client connected\n", remoteAddr)

scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := scanner.Text()
fmt.Printf("[%s] received: %s\n", remoteAddr, line)

// echo response (convert to uppercase)
response := strings.ToUpper(line) + "\n"
_, err := conn.Write([]byte(response))
if err != nil {
fmt.Printf("[%s] write failed: %v\n", remoteAddr, err)
return
}

// handle "quit" command
if strings.ToLower(line) == "quit" {
fmt.Printf("[%s] client requested disconnect\n", remoteAddr)
return
}
}

if err := scanner.Err(); err != nil {
fmt.Printf("[%s] read error: %v\n", remoteAddr, err)
}
fmt.Printf("[%s] connection closed\n", remoteAddr)
}

func startTCPServer(addr string) error {
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen failed: %w", err)
}
defer listener.Close()

fmt.Printf("TCP server started: %s\n", addr)

for {
// Accept: wait for new client connection (blocking)
conn, err := listener.Accept()
if err != nil {
// error returned when listener is closed
return fmt.Errorf("accept failed: %w", err)
}

// handle each client in a separate goroutine
go handleClient(conn)
}
}

func main() {
// run server in goroutine
go func() {
if err := startTCPServer(":9000"); err != nil {
fmt.Println("server error:", err)
}
}()

// wait briefly then run test client
time.Sleep(100 * time.Millisecond)

// test client
conn, err := net.Dial("tcp", ":9000")
if err != nil {
fmt.Println("client connection failed:", err)
return
}
defer conn.Close()

messages := []string{"hello", "world", "quit"}
for _, msg := range messages {
fmt.Fprintf(conn, "%s\n", msg)

buf := make([]byte, 1024)
n, _ := conn.Read(buf)
fmt.Printf("response: %s", string(buf[:n]))
}
}

TCP Echo Server + Client — Complete Implementation

package main

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

// TCPServer: TCP server with connection management
type TCPServer struct {
addr string
listener net.Listener
wg sync.WaitGroup
quit chan struct{}
}

func NewTCPServer(addr string) *TCPServer {
return &TCPServer{
addr: addr,
quit: make(chan struct{}),
}
}

func (s *TCPServer) Start() error {
ln, err := net.Listen("tcp", s.addr)
if err != nil {
return err
}
s.listener = ln
fmt.Printf("server started: %s\n", s.addr)

s.wg.Add(1)
go s.acceptLoop()
return nil
}

func (s *TCPServer) Stop() {
close(s.quit)
s.listener.Close()
s.wg.Wait()
fmt.Println("server stopped")
}

func (s *TCPServer) acceptLoop() {
defer s.wg.Done()
for {
conn, err := s.listener.Accept()
if err != nil {
select {
case <-s.quit:
return // graceful shutdown
default:
fmt.Println("accept error:", err)
continue
}
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.handleConn(conn)
}()
}
}

func (s *TCPServer) handleConn(conn net.Conn) {
defer conn.Close()
fmt.Printf("connected: %s\n", conn.RemoteAddr())

// simple echo using io.Copy
io.Copy(conn, conn)
fmt.Printf("disconnected: %s\n", conn.RemoteAddr())
}

// runEchoClient: client that sends messages to the echo server
func runEchoClient(addr string, messages []string) {
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
fmt.Println("connection failed:", err)
return
}
defer conn.Close()

reader := bufio.NewReader(conn)

for _, msg := range messages {
// send to server
fmt.Fprintf(conn, "%s\n", msg)

// receive echo
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
resp, err := reader.ReadString('\n')
if err != nil {
fmt.Println("receive error:", err)
return
}
fmt.Printf("sent: %q → received: %q\n", msg, resp)
}
}

func main() {
server := NewTCPServer(":9001")
if err := server.Start(); err != nil {
fmt.Println("server start failed:", err)
return
}
defer server.Stop()

time.Sleep(50 * time.Millisecond)

runEchoClient(":9001", []string{"hello", "echo test", "Go networking"})
}

UDP Socket Programming

UDP transmits packets without establishing a connection. It's ideal for latency-sensitive applications like real-time games, DNS, and video streaming.

package main

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

// UDP server
func startUDPServer(addr string) {
pc, err := net.ListenPacket("udp", addr)
if err != nil {
fmt.Println("UDP server start failed:", err)
return
}
defer pc.Close()

fmt.Printf("UDP server started: %s\n", addr)

buf := make([]byte, 1024)
for {
// receive packet (includes sender address)
n, addr, err := pc.ReadFrom(buf)
if err != nil {
fmt.Println("ReadFrom error:", err)
return
}

msg := string(buf[:n])
fmt.Printf("UDP received [%s]: %s\n", addr, msg)

// echo response
response := "echo: " + msg
pc.WriteTo([]byte(response), addr)
}
}

// UDP client
func runUDPClient(serverAddr string) {
conn, err := net.Dial("udp", serverAddr)
if err != nil {
fmt.Println("UDP connection failed:", err)
return
}
defer conn.Close()

messages := []string{"first message", "second message"}
buf := make([]byte, 1024)

for _, msg := range messages {
conn.Write([]byte(msg))

conn.SetReadDeadline(time.Now().Add(2 * time.Second))
n, err := conn.Read(buf)
if err != nil {
fmt.Println("UDP receive failed:", err)
continue
}
fmt.Printf("UDP response: %s\n", string(buf[:n]))
}
}

func main() {
go startUDPServer(":9002")
time.Sleep(50 * time.Millisecond)

runUDPClient(":9002")
time.Sleep(100 * time.Millisecond)
}

Unix Domain Sockets

Faster than TCP for inter-process communication on the same host, with access control via filesystem permissions.

package main

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

func main() {
socketPath := "/tmp/myapp.sock"
os.Remove(socketPath) // clean up previous socket file

// Unix socket server
go func() {
ln, err := net.Listen("unix", socketPath)
if err != nil {
fmt.Println("Unix socket listen failed:", err)
return
}
defer ln.Close()
defer os.Remove(socketPath)

fmt.Println("Unix socket server started:", socketPath)
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()

scanner := bufio.NewScanner(conn)
for scanner.Scan() {
fmt.Println("Unix received:", scanner.Text())
conn.Write([]byte("response: " + scanner.Text() + "\n"))
}
}()

time.Sleep(50 * time.Millisecond)

// Unix socket client
conn, err := net.Dial("unix", socketPath)
if err != nil {
fmt.Println("Unix socket connection failed:", err)
return
}
defer conn.Close()

fmt.Fprintf(conn, "inter-process communication test\n")

buf := make([]byte, 256)
n, _ := conn.Read(buf)
fmt.Println("Unix response:", string(buf[:n]))
}

Real-World Example 1 — Simple Chat Server (TCP Multi-Client)

package main

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

// ChatServer: multi-client chat server
type ChatServer struct {
clients map[net.Conn]string // conn -> nickname
mu sync.Mutex
broadcast chan string
}

func NewChatServer() *ChatServer {
return &ChatServer{
clients: make(map[net.Conn]string),
broadcast: make(chan string, 100),
}
}

func (s *ChatServer) addClient(conn net.Conn, nick string) {
s.mu.Lock()
defer s.mu.Unlock()
s.clients[conn] = nick
}

func (s *ChatServer) removeClient(conn net.Conn) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.clients, conn)
}

// broadcastMessage: send message to all clients
func (s *ChatServer) broadcastMessage(msg string, exclude net.Conn) {
s.mu.Lock()
defer s.mu.Unlock()
for conn := range s.clients {
if conn != exclude {
fmt.Fprint(conn, msg)
}
}
}

func (s *ChatServer) handleClient(conn net.Conn) {
defer func() {
nick := ""
s.mu.Lock()
nick = s.clients[conn]
s.mu.Unlock()

s.removeClient(conn)
conn.Close()
s.broadcastMessage(fmt.Sprintf("*** %s has left ***\n", nick), nil)
}()

// request nickname
conn.Write([]byte("Enter your nickname: "))
scanner := bufio.NewScanner(conn)

if !scanner.Scan() {
return
}
nick := strings.TrimSpace(scanner.Text())
if nick == "" {
nick = "anonymous"
}

s.addClient(conn, nick)
s.broadcastMessage(fmt.Sprintf("*** %s has joined ***\n", nick), conn)
conn.Write([]byte(fmt.Sprintf("Welcome, %s!\n", nick)))

for scanner.Scan() {
msg := scanner.Text()
if msg == "" {
continue
}
formatted := fmt.Sprintf("[%s] %s: %s\n",
time.Now().Format("15:04:05"), nick, msg)
fmt.Print(formatted) // print to server console
s.broadcastMessage(formatted, conn) // send to other clients
conn.Write([]byte("(sent)\n")) // confirm to sender
}
}

func (s *ChatServer) Listen(addr string) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
defer ln.Close()
fmt.Printf("chat server started: %s\n", addr)

for {
conn, err := ln.Accept()
if err != nil {
return err
}
go s.handleClient(conn)
}
}

func main() {
server := NewChatServer()
fmt.Println("starting chat server...")
fmt.Println("connect with: telnet localhost 9003")

// in production, uncomment below:
// server.Listen(":9003")

// demo: run server in goroutine and test with 2 clients
go server.Listen(":9003")
time.Sleep(50 * time.Millisecond)

// client 1
c1, _ := net.Dial("tcp", ":9003")
defer c1.Close()
c1.Read(make([]byte, 100)) // receive prompt
fmt.Fprintf(c1, "Alice\n")

time.Sleep(50 * time.Millisecond)

// client 2
c2, _ := net.Dial("tcp", ":9003")
defer c2.Close()
c2.Read(make([]byte, 100))
fmt.Fprintf(c2, "Bob\n")

time.Sleep(50 * time.Millisecond)

// Alice sends a message
fmt.Fprintf(c1, "Hello everyone!\n")
time.Sleep(100 * time.Millisecond)

// Bob receives
buf := make([]byte, 256)
c2.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
n, _ := c2.Read(buf)
fmt.Printf("Bob received: %s", string(buf[:n]))
}

Real-World Example 2 — Port Scanner

package main

import (
"fmt"
"net"
"sort"
"sync"
"time"
)

// scanPort: scan a single port (returns true if open)
func scanPort(host string, port int, timeout time.Duration) bool {
addr := fmt.Sprintf("%s:%d", host, port)
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return false
}
conn.Close()
return true
}

// scanPorts: scan a range of ports in parallel
func scanPorts(host string, startPort, endPort int, timeout time.Duration, workers int) []int {
var (
openPorts []int
mu sync.Mutex
wg sync.WaitGroup
)

// worker pool pattern to limit concurrency
ports := make(chan int, workers)

for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for port := range ports {
if scanPort(host, port, timeout) {
mu.Lock()
openPorts = append(openPorts, port)
mu.Unlock()
fmt.Printf(" [open] port %d\n", port)
}
}
}()
}

// send ports to scan
for port := startPort; port <= endPort; port++ {
ports <- port
}
close(ports)
wg.Wait()

sort.Ints(openPorts)
return openPorts
}

func main() {
host := "localhost"
start, end := 1, 1024
timeout := 100 * time.Millisecond
workers := 100

fmt.Printf("scanning ports: %s (%d–%d)\n", host, start, end)
startTime := time.Now()

openPorts := scanPorts(host, start, end, timeout, workers)

elapsed := time.Since(startTime)
fmt.Printf("\nscan complete (elapsed: %v)\n", elapsed)
fmt.Printf("open ports: %d\n", len(openPorts))
if len(openPorts) > 0 {
fmt.Printf("open port list: %v\n", openPorts)
}
}

Pro Tips

Always set timeouts: Use net.DialTimeout instead of net.Dial, and set SetDeadline for read/write timeouts after connecting. Network code without timeouts can cause goroutines to block forever during failures.

defer conn.Close() pattern: Whenever you open a connection, write defer conn.Close() on the very next line. Inside an Accept loop, close inside a closure running in a goroutine.

UDP packet size limit: The theoretical UDP payload limit is 65507 bytes, but keep it under 1472 bytes (Ethernet MTU minus headers) in practice to avoid fragmentation.

SO_REUSEADDR: If you get address already in use when restarting a server, use net.ListenConfig to set socket options.