Go Language Basics for Networking
If you completed Go Fundamentals, you already know variables, structs, functions, slices, maps, goroutines, and channels — skip to Chapter 3. This chapter is a condensed networking-oriented refresher for readers who started directly in Part 2.
This chapter covers the three Go patterns that underpin every networked program in this book: modelling connections with structs, error handling around I/O, and the goroutine-per-connection accept loop. If any section below feels unfamiliar, the corresponding Go Fundamentals chapter is referenced alongside it.
Go's networking code isn't a special dialect of the language — it's ordinary Go: structs, errors, and goroutines, applied to bytes moving over a wire.
Modelling a Connection with Structs
A Server struct holds configuration; a Client struct holds a live connection. The difference between value and pointer receivers matters immediately:
type Server struct {
Host string
Port int
}
func (s Server) Address() string {
return fmt.Sprintf("%s:%d", s.Host, s.Port)
}
Address uses a value receiver — it only reads fields, so a copy is fine.
type Client struct {
conn net.Conn
tag string
}
func (c *Client) Connect(addr string) error {
conn, err := net.Dial("tcp", addr)
if err != nil {
return err
}
c.conn = conn
return nil
}
Connect uses a pointer receiver (*Client) because it mutates c.conn. A value receiver would update a throwaway copy, leaving the original unchanged.
Client by value copies the net.Conn field. Both copies share the same underlying connection, but writes to one copy's other fields never reach the original — a frequent source of silent bugs.Error Handling Around I/O
Every networking call can fail. Go's explicit error handling makes that failure visible:
conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
log.Fatal(err)
}
net package errors implement net.Error, which adds Timeout() bool:
if ne, ok := err.(net.Error); ok && ne.Timeout() {
// retry on timeout, give up on permanent failure
}
log.Fatal calls os.Exit(1) immediately — any defer conn.Close() written earlier never runs. Inside a server, log.Fatal on a per-connection error kills the entire process. Prefer log.Println plus return or continue.The Goroutine-Per-Connection Accept Loop
The pattern that makes every Go network server possible:
ln, err := net.Listen("tcp", ":9000")
if err != nil {
log.Fatal(err)
}
for {
conn, err := ln.Accept()
if err != nil {
log.Println("accept error:", err)
continue
}
go handleConn(conn)
}
Accept() blocks until a client connects, returns one net.Conn, then blocks again waiting for the next client. go handleConn(conn) spawns a goroutine so the loop goes straight back to blocking instead of waiting for the current client to finish. Each goroutine costs a few KB of stack — not an OS thread — so this scales to thousands of simultaneous connections.
It does not scale to infinity. A connection flood with no limit exhausts memory and file descriptors even though goroutines are cheap. Chapter 8 (Concurrency in Networking) introduces worker pools — a fixed number of goroutines pulling connections off a channel — as the escape hatch, reached for only once you have measured that unbounded goroutines are the problem.
Exercise: Goroutines and Channels
Real-World Example: A Careful TCP Client
package main
import (
"fmt"
"io"
"net"
"os"
)
func main() {
conn, err := net.Dial("tcp", "example.com:80")
if err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
defer conn.Close()
fmt.Fprintf(conn, "GET / HTTP/1.0\r\n\r\n")
buf := make([]byte, 4096)
for {
n, err := conn.Read(buf)
fmt.Print(string(buf[:n]))
if err == io.EOF {
break
}
if err != nil {
fmt.Println("read error:", err)
break
}
}
}
Key points:
defer conn.Close()guarantees cleanup even on early returns.- The
Readloop continues untilio.EOF— TCP has no concept of messages, so a singleReadmay return part of a response or more than one chunk. buf[:n]prints before checkingerr—Readcan return data andio.EOFin the same call.
Every byte a TCP connection hands you comes through the same three-step ritual: read into a slice, check the error, decide whether to keep going.
Frequently Asked Questions
Why does the careful TCP client print buf[:n] before checking for io.EOF?
Because Read can return both data and io.EOF in the same call — the last chunk of the response can arrive at the exact moment the connection closes. Checking the error first would silently drop the tail of the response.
My program printed "all goroutines are asleep - deadlock!" — what happened?
You almost certainly sent on an unbuffered channel with nothing ready to receive. ch <- "Ping!" on the same goroutine that will later read <-ch, with no go in front of the send, blocks forever. Go's runtime detects the deadlock and panics rather than hanging silently.
Should Address() on my Server struct use a value or pointer receiver?
If the method only reads fields (Host, Port), a value receiver is fine. If it mutates the struct, or the struct holds a net.Conn, mutex, or buffer, give it a pointer receiver — otherwise changes hit a throwaway copy.