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.
- Go installed.
go versionshould 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 tcptutorial2 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())
}$ go run main.go
2026/08/29 14:10:02 listening on [::]:9000The 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())$ go run main.go
2026/08/29 14:11:40 listening on [::]:9000Leave 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.
$ nc localhost 9000
2026/08/29 14:11:44 client connected from 127.0.0.1:54210Press 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)$ nc localhost 9000
hello
echo: hello
second line
echo: second lineType 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)
}$ nc localhost 9000
2026/08/29 14:20:03 listening on [::]:9000Both 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())
}$ 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 timeoutA 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())
}$ printf 'one\ntwo\n' | nc localhost 9000
echo: one
echo: twoWhat 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.