HowtoGo
Home / Tutorials / TCP Server
Tutorials

TCP Server

Build a TCP echo server from an empty folder: bind a port with net.Listen, accept connections in a loop, serve each client in its own goroutine, and time out the ones that go quiet.

This tutorial builds a TCP echo server from an empty folder. It listens on a port, accepts connections, reads lines from each one, writes them back, and serves many clients at the same time without letting a slow one block the rest.

TCP is the layer net/http is built on. Writing a server directly against it shows what a web framework is doing underneath.

Before you start
  • Go installed. go version should print something.
  • A terminal, and nc (netcat) to connect as a client.

1 Set up the project

Make a folder, turn it into a module, and create main.go.

mkdir go-tcp-tutorial
cd go-tcp-tutorial
go mod init tcptutorial

2 Open a listening socket

Add this to main.go.

package main

import (
    "log"
    "net"
)

func main() {
    ln, err := net.Listen("tcp", ":9000")
    if err != nil {
        log.Fatal(err)
    }
    defer ln.Close()

    log.Println("listening on", ln.Addr())
}
Run it
$ go run main.go
2026/08/29 14:10:02 listening on [::]:9000

The program prints the address and exits immediately, because nothing is waiting for a client yet.

net.Listen binds the port and the operating system starts queueing incoming connections right away, before your code has accepted a single one. ln.Addr reports what actually got bound, which matters when you pass ":0" and let the OS pick a free port.

3 Accept one connection

Add an accept call at the end of main, after the log line.

    conn, err := ln.Accept()
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()

    log.Println("client connected from", conn.RemoteAddr())
Run it
$ go run main.go
2026/08/29 14:11:40 listening on [::]:9000

Leave that terminal on the server and open a second one for the client. Every step after this one changes the code, so stop the server with Ctrl-C and start it again first.

Run it
$ nc localhost 9000
2026/08/29 14:11:44 client connected from 127.0.0.1:54210

Press Ctrl-C to close netcat. The server exits too, since it accepted one connection and reached the end of main.

Accept blocks until a client arrives, then hands back one net.Conn, belonging to that client alone. Each call returns a different connection.

4 Read lines and write them back

Add a function below main.

func handleConn(conn net.Conn) {
    defer conn.Close()

    scanner := bufio.NewScanner(conn)
    for scanner.Scan() {
        fmt.Fprintf(conn, "echo: %s\n", scanner.Text())
    }
    if err := scanner.Err(); err != nil {
        log.Println("read:", err)
    }
}

Add "bufio" and "fmt" to the imports, then call it in place of the log line you added in step 3.

    handleConn(conn)
Run it
$ nc localhost 9000
hello
echo: hello
second line
echo: second line

Type a line, press enter, and the server sends it back with a prefix. Ctrl-D ends the connection and Scan returns false.

A net.Conn is an io.ReadWriteCloser, so anything that reads or writes bytes works on it unchanged. bufio.Scanner is the same type used for files, and fmt.Fprintf writes to the socket the same way it writes to a file.

5 Serve many clients at once

Right now the second client waits for the first to disconnect. Wrap the accept in a loop and give each connection its own goroutine. Replace everything after the log line in main.

    for {
        conn, err := ln.Accept()
        if err != nil {
            log.Println("accept:", err)
            continue
        }
        go handleConn(conn)
    }
Run it
$ nc localhost 9000
2026/08/29 14:20:03 listening on [::]:9000

Both netcat sessions now get their own echo, at the same time.

The loop uses continue rather than log.Fatal, because one failed accept is not a reason to stop serving everyone else. go handleConn(conn) is the whole of the concurrency: the loop is free to accept the next client immediately.

The defer that closes the connection lives inside handleConn, which is the only place that knows when that client is finished.

6 Stop a silent client holding a goroutine

A client that connects and then says nothing keeps its goroutine parked in Scan forever. Enough of those and the process runs out of memory. Give each read a deadline. Add this at the top of the scan loop in handleConn.

    scanner := bufio.NewScanner(conn)
    for {
        conn.SetReadDeadline(time.Now().Add(30 * time.Second))
        if !scanner.Scan() {
            break
        }
        fmt.Fprintf(conn, "echo: %s\n", scanner.Text())
    }
Run it
$ nc localhost 9000   # then wait 30 seconds
2026/08/29 14:31:12 read: read tcp 127.0.0.1:9000->127.0.0.1:54210: i/o timeout

A deadline is an absolute time, not a duration, which is why it is set again on every pass. Setting it once before the loop would give the client thirty seconds total rather than thirty seconds of silence.

7 The finished program

Every step above, in one file.

main.go 45 lines Show
package main

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

const idleTimeout = 30 * time.Second

func main() {
    ln, err := net.Listen("tcp", ":9000")
    if err != nil {
        log.Fatal(err)
    }
    defer ln.Close()
    log.Println("listening on", ln.Addr())

    for {
        conn, err := ln.Accept()
        if err != nil {
            log.Println("accept:", err)
            continue
        }
        go handleConn(conn)
    }
}

func handleConn(conn net.Conn) {
    defer conn.Close()
    log.Println("connected:", conn.RemoteAddr())

    scanner := bufio.NewScanner(conn)
    for {
        conn.SetReadDeadline(time.Now().Add(idleTimeout))
        if !scanner.Scan() {
            break
        }
        fmt.Fprintf(conn, "echo: %s\n", scanner.Text())
    }
    if err := scanner.Err(); err != nil {
        log.Println("read:", err)
    }
    log.Println("disconnected:", conn.RemoteAddr())
}
Run it
$ printf 'one\ntwo\n' | nc localhost 9000
echo: one
echo: two

What you built

You have a TCP server that binds a port, accepts connections in a loop, gives each client its own goroutine, echoes lines back, and drops a client that has gone quiet for thirty seconds.

Four pieces did that work. net.Listen bound the port, Accept produced one net.Conn per client, bufio.Scanner turned the byte stream into lines, and SetReadDeadline put a bound on how long a connection may sit idle. A goroutine per connection is the shape almost every Go network server uses, net/http included.