07 - TCP from the Socket Up
📋 Jump to Takeaways🎁 Every web framework you've used is a few thousand lines wrapped around one small idea: accept a connection, read bytes, write bytes. Strip the framework away and what does raw TCP in Go actually look like?
TCP is a reliable, ordered stream of bytes between two endpoints. In this lesson you'll build servers and clients directly on net, with no HTTP in sight: listening, accepting connections, reading and writing raw bytes, setting deadlines, and framing your own line protocol.
The Socket Model
A TCP server does three things: listen on an address, accept connections, and handle each one. net.Listen opens the listening socket; Accept blocks until a client shows up and returns a net.Conn for that client:
ln, err := net.Listen("tcp", ":8080")
if err != nil {
log.Fatal(err)
}
defer ln.Close()
conn, err := ln.Accept() // blocks until a client connects
if err != nil {
log.Fatal(err)
}
defer conn.Close()A client is even simpler. net.Dial connects to a host:port and hands back a net.Conn you can talk over:
conn, err := net.Dial("tcp", "example.com:80")
if err != nil {
log.Fatal(err)
}
defer conn.Close()Conn Is a Reader and a Writer
A net.Conn is an io.Reader and an io.Writer, so everything you learned about reading and writing files applies directly. You send and receive plain bytes:
fmt.Fprintf(conn, "GET / HTTP/1.0\r\n\r\n") // write request bytes
buf := make([]byte, 1024)
n, _ := conn.Read(buf) // read response bytes
fmt.Println(string(buf[:n]))One thing to burn into memory: TCP is a byte stream, not a message stream. There are no message boundaries. A single Read might return half of what the peer sent, or two of its writes glued together. If your protocol has "messages," you have to frame them yourself, which we do below.
Serving Many Clients
A real server accepts in a loop and handles each connection in its own goroutine, so one slow client never blocks the others:
func serve(ln net.Listener) {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go handleConn(conn) // one goroutine per connection
}
}
func handleConn(conn net.Conn) {
defer conn.Close()
io.Copy(conn, conn) // echo everything back
}Spawning a goroutine per connection sounds expensive, but in Go it isn't: goroutines are cheap and a blocked Read doesn't tie up an OS thread. That's the netpoller's doing, and it's exactly the next lesson.
Deadlines and Timeouts
A network read blocks by default, potentially forever, so production code sets deadlines. There's no separate "timeout" option in net.Conn; deadlines are the mechanism. They take an absolute time, so you pass time.Now().Add(...):
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 1024)
_, err := conn.Read(buf)
if err != nil && errors.Is(err, os.ErrDeadlineExceeded) {
fmt.Println("client too slow, giving up")
}SetReadDeadline, SetWriteDeadline, and SetDeadline (both at once) apply to all future I/O on the connection until you set a new one. A common pattern is to push the deadline forward before each read, so an idle connection eventually times out but an active one keeps going.
Framing a Line Protocol
Because TCP has no message boundaries, you impose them. The simplest framing is one message per line, terminated by \n. bufio.Scanner reads the stream and hands you complete lines:
func handleConn(conn net.Conn) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := scanner.Text() // one full line, no newline
fmt.Fprintln(conn, strings.ToUpper(line)) // respond, one line back
}
}Now a client that sends hello\n gets HELLO\n back. The scanner handles the reassembly of partial reads for you, so you think in messages while TCP quietly deals in bytes.
Half-Close and Shutdown
TCP connections are bidirectional, and sometimes you want to say "I'm done sending, but keep listening." A *net.TCPConn supports a half-close via CloseWrite, which sends EOF to the peer while your read side stays open:
tcp := conn.(*net.TCPConn)
fmt.Fprint(tcp, "final message")
tcp.CloseWrite() // peer's Read now returns io.EOF
reply, _ := io.ReadAll(tcp) // still able to read their response
fmt.Println(string(reply))To stop a server, close the listener: the blocked Accept returns an error and your accept loop exits. Combine that with the signal.NotifyContext pattern from Signals & Lifecycle for clean shutdown.
The Layer Above
Everything here is the transport layer, the plumbing that just moves bytes between two machines. HTTP, gRPC, and every web framework are built on exactly this: a listener, accepted connections, and byte streams, with a protocol layered on top. When you want to build an actual web service with routing and middleware, that's the job of Go in Practice. This lesson is the floor it all stands on.
Key Takeaways
- A TCP server does
net.Listen("tcp", addr)then loops onln.Accept()(returns anet.Connper client); a client usesnet.Dial("tcp", host:port). net.Connis anio.Reader/io.Writer; you read and write raw bytes. TCP is a byte stream with no message boundaries, so aReadmay split or merge peer writes.- Handle each connection in its own goroutine (
go handleConn(conn)); it scales because blocked reads don't consume OS threads. - Set deadlines with
SetReadDeadline/SetWriteDeadline/SetDeadlineusing absolute times (time.Now().Add(...)); a passed deadline yieldsos.ErrDeadlineExceeded. There is no separate timeout option. - Frame messages yourself; one line per
\nwithbufio.Scanneris the simplest scheme. (*net.TCPConn).CloseWrite()half-closes (sends EOF while still reading); closing the listener stopsAccept.
🎁 TCP guarantees every byte arrives in order, but that reliability costs handshakes and acknowledgments. What if you're streaming live video or firing off metrics, where speed matters more than perfection and a dropped packet is no big deal?