Go by Example

TCP Server

net.Listen + Accept in a loop; handle each net.Conn in a goroutine and set deadlines - without them a stalled client holds a goroutine open for the process lifetime.

net.Listen binds a TCP address and returns a net.Listener. Calling Accept in a loop gives you a net.Conn for each client - a bidirectional stream you read from and write to. Handle each connection in a goroutine so the server can serve multiple clients concurrently.

The core loop: Listen, then Accept in a loop, then hand each net.Conn to a goroutine. The goroutine reads a line, echoes it back, and closes the connection.

package main
 
import (
    "bufio"
    "fmt"
    "net"
    "strings"
)
 
func handleConn(conn net.Conn) {
    defer conn.Close()
    scanner := bufio.NewScanner(conn)
    for scanner.Scan() {
        line := scanner.Text()
        fmt.Fprintf(conn, "echo: %s\n", strings.ToUpper(line))
    }
}
 
func main() {
    ln, err := net.Listen("tcp", ":9000")
    if err != nil {
        panic(err)
    }
    defer ln.Close()
    fmt.Println("tcp server listening on :9000")
 
    for {
        conn, err := ln.Accept()
        if err != nil {
            fmt.Println("accept error:", err)
            continue
        }
        go handleConn(conn)
    }
}

Set deadlines on the connection to prevent slow or stalled clients from holding a goroutine forever. SetDeadline applies to both reads and writes; SetReadDeadline and SetWriteDeadline set them independently.

package main
 
import (
    "bufio"
    "fmt"
    "net"
    "time"
)
 
func handleConn(conn net.Conn) {
    defer conn.Close()
 
    // Extend the deadline on each successful read so active clients never time out
    scanner := bufio.NewScanner(conn)
    for scanner.Scan() {
        conn.SetDeadline(time.Now().Add(30 * time.Second))
        line := scanner.Text()
        fmt.Fprintf(conn, "received: %s\n", line)
    }
    if err := scanner.Err(); err != nil {
        fmt.Println("read error:", err)
    }
}
 
func main() {
    ln, err := net.Listen("tcp", ":9000")
    if err != nil {
        panic(err)
    }
    defer ln.Close()
 
    for {
        conn, err := ln.Accept()
        if err != nil {
            continue
        }
        // Set an initial deadline before the first read
        conn.SetDeadline(time.Now().Add(30 * time.Second))
        go handleConn(conn)
    }
}

In production

Raw TCP servers underpin database drivers, message queues, and custom protocols (Redis, Memcached, NATS). Always handle each accepted connection in a goroutine - a blocking handleConn on the accept goroutine would serialize all clients. Set SetReadDeadline and SetWriteDeadline to prevent a slow or stalled client from holding a goroutine and its stack indefinitely; without deadlines, a client that opens a connection and never sends data occupies a goroutine for the lifetime of the process. For production servers, bound the total number of concurrent connections with a semaphore (a buffered channel of capacity N) - unbounded goroutine creation from untrusted input is a denial-of-service vector. For graceful shutdown, stop calling Accept (close the listener) and wait for active handleConn goroutines to drain using a sync.WaitGroup. If your protocol is line-oriented, bufio.Scanner is convenient; for binary protocols with length-prefixed frames, use encoding/binary with io.ReadFull to read exactly N bytes at a time.

Enjoyed this? Get more essays on software craft delivered to your inbox.

Subscribe free