HTTP rides on top of TCP, and every net/http server in Go is really a TCP server with a request parser bolted on. When you need a custom wire protocol, a long-lived connection, or anything that doesn't fit request-response, you drop down to the net package and speak TCP directly. This lesson covers how to listen, accept connections, handle each one in its own goroutine, set deadlines, and shut the whole thing down cleanly.
A TCP server starts with a listener. net.Listen binds to a port and returns a net.Listener you can call Accept on. Each Accept call blocks until a new client connects, then returns a net.Conn representing that connection.
The address ":4000" means "listen on port 4000 on every local network interface". Use "127.0.0.1:4000" to bind only to the loopback interface, which is useful when you don't want the server reachable from the outside network. The accept loop runs forever, and ln.Close() is the signal that breaks it: a closed listener makes Accept return an error.
Cost: A net.Listener holds a file descriptor. On Linux, the default per-process limit is often 1024. A server that accepts many concurrent connections may need ulimit -n raised. Each accepted connection also consumes a descriptor until it's closed.
A server that handles connections one at a time is unusable, since one slow client blocks every other client behind it. Go's answer is one goroutine per connection. The accept loop hands each new conn to a handler goroutine and goes back to accepting.
The diagram shows the shape of a typical Go TCP server. One listener, one accept loop, N goroutines (one per live connection). The goroutines run in parallel on whatever cores the runtime gives them, so a slow client only stalls its own goroutine.
defer conn.Close() is the first line of handleConn. Whatever exits the function, normal return or panic, the connection closes. The goroutine owns the connection. Don't share conn with other goroutines without synchronization, since concurrent reads and writes on the same net.Conn aren't safe.
Cost: A goroutine costs roughly 2-8 KB of stack at start, growing as needed. Tens of thousands of idle connections are fine on modest hardware, but a million of them isn't free, and you'll want a different design (like an event loop) at that scale.
A working server needs a protocol. The simplest one is line-based: each request is one line ending in \n, and each response is one line ending in \n. The store-room service below handles a single command:
STOCK <productID>\nOK <count>\n on success, ERR not found\n on a missing product, ERR bad request\n on malformed input.Reading lines means picking a parser. bufio.NewScanner is the easiest option: it splits on newlines by default and hands you one line per Scan() call. bufio.NewWriter buffers writes so you don't pay a syscall per byte.
The loop keeps reading lines until Scan() returns false, which happens when the client closes the connection or the scanner hits an error. writer.Flush() after each response pushes the buffered bytes onto the wire so the client doesn't sit waiting.
bufio.Scanner has a default max line length of 64 KB. Send a longer line and Scan returns false with bufio.ErrTooLong. Raise the cap with Scanner.Buffer:
That call gives the scanner a 1 MB ceiling instead of 64 KB. For an inventory protocol, the default is plenty, but JSON payloads or binary blobs blow through it fast.
A connected client that never sends anything pins a goroutine forever. The handler is blocked inside scanner.Scan, waiting on a read that may never complete. Without a deadline, an attacker could open thousands of connections and exhaust your goroutine budget without sending a single byte.
SetReadDeadline puts a wall-clock limit on the next read. When the deadline passes, the read returns a timeout error. SetWriteDeadline does the same for writes. SetDeadline sets both at once.
Each iteration resets the read deadline before calling Scan. A client that goes silent for 30 seconds gets dropped. An active client that sends a line every few seconds renews the deadline naturally on each loop pass. Deadlines are per-operation, not per-connection: push them forward as work happens.
Cost: Without read deadlines, a slow-loris-style attacker can hold open connections cheaply. Each idle goroutine costs little CPU but holds a file descriptor and 2-8 KB of stack. At 100,000 connections, that's hundreds of megabytes of stack plus the descriptor limit issue.
The pieces fit together in a single main that spins up the server, dials it, exchanges messages, and shuts down.
The client side is just net.Dial, which returns a net.Conn connected to the server. Writes go to the server with fmt.Fprintf, and bufio.Reader.ReadString('\n') pulls back one line at a time. The five requests cover all three response types and an invalid line.
When you want the server to stop, two things need to happen. The accept loop has to exit, and any in-flight handlers should get a chance to finish or wind down cleanly.
Closing the listener handles the first part. ln.Close() makes Accept return immediately with an error, and the accept loop sees the error and exits. Handlers running in goroutines don't know the listener closed unless you tell them. That's what context.Context is for: a done signal you pass into each handler.
cancel() flips ctx.Done() for every handler. Each handler's loop sees the channel close on the next iteration and exits. ln.Close() breaks the accept loop. wg.Wait() blocks main until every active handler returns. The combination gives you a clean shutdown without orphaned goroutines.
net.TCPConn is the concrete type behind net.Conn for TCP, and it exposes a few tuning options worth knowing about. You rarely need them, but they exist for the cases where you do.
| Method | What It Does | When You Care |
|---|---|---|
SetKeepAlive(true) | Enables TCP-level keepalive probes from the OS. | Long-lived idle connections, when you want dead peers detected by the kernel without app-level pings. |
SetKeepAlivePeriod(d) | Sets how often keepalive probes are sent. | Tuning detection speed for half-open connections. |
SetNoDelay(false) | Disables Nagle's algorithm coalescing (default is true, meaning Nagle is on for low-latency writes). | Latency-sensitive protocols with many small writes. The default is usually fine. |
SetLinger(seconds) | Controls how Close handles unsent data. | Rare. Usually leave the default. |
To use them, type-assert the net.Conn you got from Accept:
Most servers leave these alone. The Go defaults pick reasonable values, and adjusting them usually means addressing a problem that should be profiled first.
10 quizzes