Back to Go Examples

Project: TCP Chat Server

A multi-client chat room in about 80 lines. One goroutine per connection, one channel to broadcast.

Contents

  1. What You'll Build
  2. The Design
  3. The Complete Code
  4. How It Works
  5. Run It
  6. Extensions

1. What You'll Build

A TCP server that anyone can connect to with telnet or nc. Everything one client types is broadcast to every other connected client. Join and leave events are announced.

# Terminal A (server)
$ go run main.go
chat server listening on :8080

# Terminal B (client)
$ telnet localhost 8080
welcome — what's your name?
> alice
* alice joined

# Terminal C (client)
$ telnet localhost 8080
welcome — what's your name?
> bob
* bob joined
alice: hi everyone

No external libraries. Just net and goroutines.

2. The Design

Three moving parts:

The broadcaster owns the map of clients, so there are no mutexes — only one goroutine ever touches that data. This is the classic Go pattern: don't communicate by sharing memory; share memory by communicating.

3. The Complete Code

package main

import (
    "bufio"
    "fmt"
    "log"
    "net"
)

type client chan<- string  // outgoing messages to one user

var (
    entering = make(chan client)
    leaving  = make(chan client)
    messages = make(chan string)
)

func broadcaster() {
    clients := make(map[client]bool)
    for {
        select {
        case msg := <-messages:
            for cli := range clients {
                cli <- msg
            }
        case cli := <-entering:
            clients[cli] = true
        case cli := <-leaving:
            delete(clients, cli)
            close(cli)
        }
    }
}

func handleConn(conn net.Conn) {
    ch := make(chan string)  // outgoing for this client
    go clientWriter(conn, ch)

    fmt.Fprintln(conn, "welcome — what's your name?")
    scan := bufio.NewScanner(conn)
    if !scan.Scan() {
        conn.Close()
        return
    }
    who := scan.Text()

    ch <- "you are " + who
    messages <- "* " + who + " joined"
    entering <- ch

    for scan.Scan() {
        messages <- who + ": " + scan.Text()
    }

    leaving <- ch
    messages <- "* " + who + " left"
    conn.Close()
}

func clientWriter(conn net.Conn, ch <-chan string) {
    for msg := range ch {
        fmt.Fprintln(conn, msg)
    }
}

func main() {
    listener, err := net.Listen("tcp", ":8080")
    if err != nil {
        log.Fatal(err)
    }
    log.Println("chat server listening on :8080")

    go broadcaster()

    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Print(err)
            continue
        }
        go handleConn(conn)
    }
}

4. How It Works

Trace one message through the system:

  1. You connect. main's Accept() returns a new conn, and go handleConn(conn) spins up a goroutine just for you.
  2. Your writer is launched. Inside handleConn, the first thing it does is start clientWriter as another goroutine. That writer's only job is to drain your personal ch channel and write each message to your socket.
  3. You enter the room. The line entering <- ch tells the broadcaster: “add this client's channel to the map.”
  4. You type a message. scan.Scan() reads a line. messages <- who + ": " + scan.Text() hands it to the broadcaster.
  5. Broadcaster fans out. It iterates over its map and sends the string to every client's channel.
  6. Each writer prints it. Each clientWriter goroutine wakes up, receives the message, and writes it to that user's socket.
Notice what's not here: no locks, no mutexes, no shared mutable state. The only goroutine that touches the clients map is the broadcaster. Everyone else communicates through channels.

5. Run It

$ mkdir chat && cd chat
$ go mod init chat
# paste the code into main.go
$ go run main.go
chat server listening on :8080

# in two or three other terminals:
$ telnet localhost 8080
$ nc localhost 8080         # if telnet isn't installed

Type a name when prompted, then start typing messages. Open more terminals and watch the messages fan out in real time.

6. Extensions

This pattern — one owner goroutine with a switch over channels, plus N worker goroutines that send into it — scales further than you'd guess. Real production servers use exactly this shape for connection multiplexing, job queues, and game state.