📞 Analogy
TCP is a phone call: you dial, a circuit opens, and everything you say arrives at the other end in order, exactly once — or the call drops and you both know. UDP (next pages) is a postcard: cheap, fire-and-forget, maybe lost. Most of the web rides on the phone call.
A reliable byte stream
Go’s net package models a TCP connection as a net.Conn — which is just an io.Reader and an io.Writer with an address and deadlines bolted on. That single fact is the key to the whole package: because a connection is a Reader/Writer, everything that works on files and buffers — io.Copy, bufio, json.NewDecoder — works on a socket unchanged. A server listens and accepts; a client dials:
graph LR C["client<br/>net.Dial"] -->|"3-way handshake"| S["server<br/>net.Listen + Accept"] C -->|"Write bytes →"| S S -->|"← Write bytes"| C C -.->|"Close"| S
// server: accept connections, a goroutine each
ln, err := net.Listen("tcp", ":9000")
if err != nil { log.Fatal(err) }
defer ln.Close()
for {
conn, err := ln.Accept()
if err != nil { continue }
go handle(conn) // one goroutine per connection
}
// client: dial and use the Conn as a Reader/Writer
conn, err := net.Dial("tcp", "example.com:9000")
if err != nil { log.Fatal(err) }
defer conn.Close()
fmt.Fprintln(conn, "hello")
The Conn interface, hands-on
Real sockets need a network, but a net.Conn is an interface — so we can exercise the exact same Read/Write mechanics in memory with net.Pipe(), which returns two connected ends. This runs anywhere, no network involved:
package main
import (
"bufio"
"fmt"
"io"
"net"
)
func main() {
// net.Pipe gives two ends of an in-memory connection — the SAME
// net.Conn interface a TCP socket implements, with no network at all.
server, client := net.Pipe()
// the "server" side: read one line, write a framed reply, then close
go func() {
defer server.Close()
line, _ := bufio.NewReader(server).ReadString('\n')
fmt.Fprintf(server, "echo: %s", line)
}()
// the "client" side: send a request, read until the server closes
fmt.Fprintf(client, "hello\n")
reply, _ := io.ReadAll(client)
fmt.Print(string(reply)) // echo: hello
client.Close()
}
Framing: where does a message end?
TCP is a stream, not a sequence of messages. One Write of 100 bytes may arrive as two Reads of 60 and 40 — or two Writes may coalesce into one Read. So you need framing: an agreed rule for where each message ends. The two common rules are a delimiter (a newline, say) and a length prefix. Here the server bufio.Scanners on newlines and correctly recovers three distinct messages no matter how TCP chunks them — all over an in-memory net.Pipe:
package main
import (
"bufio"
"fmt"
"io"
"net"
)
func main() {
server, client := net.Pipe()
// server: split the byte stream back into messages on '\n'
done := make(chan struct{})
go func() {
defer close(done)
sc := bufio.NewScanner(server)
for sc.Scan() {
fmt.Printf("message: %q\n", sc.Text())
}
}()
// client: three logical messages, newline-framed
io.WriteString(client, "alpha\nbravo\n")
io.WriteString(client, "charlie\n") // a separate Write — framing still works
client.Close() // signals EOF; scanner loop ends
<-done
}
For binary protocols a delimiter is fragile (the payload might contain the delimiter byte), so you prefix each message with its length. That pattern — and designing a full protocol with a header and checksum — gets its own page: framing & custom protocols.
Deadlines, not blocking forever
A network read can hang indefinitely — a peer that opened a connection and then went silent will park your goroutine forever. Deadlines are the fix: they make a stalled Read/Write return a net.Error with Timeout()==true once the wall-clock deadline passes. (Deadlines are absolute times, not durations — re-set them before each operation.) net.Pipe honors deadlines too, so this runs in-process:
package main
import (
"errors"
"fmt"
"net"
"time"
)
func main() {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
// No one ever writes to this pipe, so the Read would block forever —
// the deadline turns that into a timeout error instead.
client.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
buf := make([]byte, 16)
_, err := client.Read(buf)
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
fmt.Println("read timed out — peer is idle, connection still usable")
} else {
fmt.Println("unexpected:", err)
}
}
In real code: conn.SetDeadline(time.Now().Add(30*time.Second)) for the whole connection, or SetReadDeadline/SetWriteDeadline separately. A timeout is not fatal — extend and retry, or treat it as an idle client and close.
One goroutine per connection
The idiomatic server is an Accept loop that hands each connection to its own goroutine. Goroutines are cheap (a few KB), so this scales to tens of thousands of connections — the runtime multiplexes them onto threads and the OS poller for you, no thread pool to size:
func handle(conn net.Conn) {
defer conn.Close()
conn.SetDeadline(time.Now().Add(30 * time.Second))
sc := bufio.NewScanner(conn)
for sc.Scan() {
line := sc.Text()
fmt.Fprintf(conn, "you said: %s\n", line) // echo, framed
}
}
for {
conn, err := ln.Accept()
if err != nil { return } // listener closed
go handle(conn) // never block the accept loop
}
The rule: never do slow work in the Accept loop itself — accept, hand off, loop. Any state shared between connection goroutines must be synchronized (a sync.Mutex or a channel).
Reference
| Need | Call |
|---|---|
| Listen for connections | net.Listen("tcp", ":9000") |
| Accept one connection | ln.Accept() → net.Conn |
| Dial a server | net.Dial("tcp", "host:port") |
| Dial with a timeout | net.DialTimeout / net.Dialer{Timeout}.DialContext(ctx, …) |
| Read/write | it’s an io.Reader/io.Writer (use bufio, io.Copy, json) |
| Frame messages | bufio.Scanner (delimiter) or a length prefix |
| Cap a stalled op | conn.SetDeadline / SetReadDeadline / SetWriteDeadline |
| Detect a timeout | errors.As(err, &netErr); netErr.Timeout() |
| Half-close (writer) | conn.(*net.TCPConn).CloseWrite() |
| Close | conn.Close() (always defer it) |
🔒 Safe-to-run note + the framing trap
These playgrounds run in-process (net.Pipe, no sockets, no real hosts) so they’re safe anywhere — the go.dev sandbox blocks real network access regardless. When you run the fenced examples locally, bind servers to a localhost/:port you control, never expose a debug server on 0.0.0.0 in production, and always defer conn.Close(). The classic bug: assuming one Write equals one Read. It doesn’t — always frame (delimiter or length prefix) and set deadlines so a silent peer can’t wedge a goroutine.
See also
- UDP sockets — the connectionless cousin, with message boundaries but no reliability.
- framing & custom protocols — length-prefix framing and designing a binary protocol on top of a stream.
- fmt & io — the
io.Reader/io.Writerinterfaces anet.Connis built on. - the Go scheduler — why one goroutine per connection scales.
- TLS & HTTPS — wrapping a TCP connection in encryption.
Next: the connectionless cousin — UDP sockets.
Related topics
Connectionless datagrams in Go — net.ListenPacket and ReadFrom/WriteTo, DialUDP, message boundaries with no delivery guarantees, encoding a datagram payload, MTU and fragmentation, and when UDP beats TCP.
net-basicsFraming & Custom ProtocolsTurning a TCP byte stream into messages — delimiter vs length-prefix framing, encoding/binary and network byte order, io.ReadFull, and designing a header-plus-payload protocol with a version field and checksum.
httpHTTP ServerServing HTTP in net/http — handlers and HandlerFunc, the ResponseWriter and Request, the Go 1.22 method+pattern ServeMux with PathValue, decoding request bodies, and a production-shaped http.Server with timeouts.
Check your understanding
Score: 0 / 51. What does TCP guarantee that UDP does not?
TCP is a connection-oriented, reliable, ordered stream of bytes. It does NOT preserve message boundaries — one Write can arrive as several Reads and vice versa, so you frame messages yourself. UDP is connectionless and lossy.
2. What's the idiomatic Go pattern for a TCP server handling many clients?
for { conn, _ := ln.Accept(); go handle(conn) }. Goroutines are cheap, so a goroutine per connection scales to tens of thousands — the runtime multiplexes them onto threads and epoll/kqueue for you.
3. Why must you frame your own messages over TCP?
A single logical message may span multiple TCP segments, and multiple Writes may coalesce. Use a delimiter (bufio.Scanner on '\n'), a fixed length, or a length prefix to know where one message ends.
4. What is a net.Conn, in interface terms?
net.Conn embeds io.Reader and io.Writer, so every connection is a stream you can io.Copy, wrap in bufio, or hand to json.NewDecoder — the same plumbing as files. It adds Close, LocalAddr/RemoteAddr, and SetDeadline.
5. A blocked conn.Read returns an error whose Timeout() is true. What happened, and is it fatal?
SetReadDeadline makes a stalled Read return a net.Error with Timeout()==true once the deadline passes. The connection is still usable — extend the deadline and read again, or treat it as an idle-timeout and close. Check via errors.As(err, &netErr).
Comments
Sign in with GitHub to join the discussion.