How to Use net.Conn and net.Listener in Go

Web
Create a net.Listener with net.Listen and use Accept to get a net.Conn for bidirectional data transfer.

The host stand and the table

You build a TCP server. It starts listening on a port. A client connects. You read a message, send a response, and close the connection. Then a second client connects. Your server is still sitting in the Accept call, waiting for the first client to finish. The second client waits. And waits. Eventually, the connection times out.

You realized your server can only talk to one person at a time. You need a way to accept new connections without blocking the ability to handle the current ones.

Think of a restaurant. The host stand is the net.Listener. It waits for people to walk in. When someone arrives, the host greets them and assigns a table. The host doesn't serve food; the host just manages the queue of new arrivals. The table is the net.Conn. The waiter works at the table, taking orders and delivering food. The host stand can keep greeting new people while the waiters are busy at the tables.

In Go, net.Listen creates the host stand. Accept is the call that blocks until a new guest arrives. When Accept returns, you get a net.Conn, which is the table. You spawn a goroutine to act as the waiter for that table, then loop back to Accept to greet the next guest.

net.Listener and net.Conn

net.Listen asks the operating system to reserve a port and return a net.Listener. The listener is an interface with two methods: Accept and Close. You rarely implement this interface yourself; you use the one returned by net.Listen.

Accept is the heart of the server loop. It blocks the calling goroutine until a TCP handshake completes. When a client connects, Accept returns a net.Conn and nil error. If the listener is closed, Accept returns an error immediately, which lets you break out of the loop and shut down.

net.Conn is also an interface. It defines the contract for any network connection: Read, Write, Close, LocalAddr, RemoteAddr, and deadline setters. You don't need to know the concrete type. You just call the methods. This design lets you swap out the underlying transport or mock connections for tests without changing your handler logic.

Go convention says "accept interfaces, return structs." net.Listen returns an interface, so your code depends on the abstraction, not the implementation.

Minimal echo server

Here's the skeleton of a TCP server: listen, accept in a loop, spawn a goroutine per connection.

package main

import (
	"log"
	"net"
)

func main() {
	// Listen on port 8080 for TCP traffic.
	// Returns a Listener that blocks on Accept until a client connects.
	ln, err := net.Listen("tcp", ":8080")
	if err != nil {
		log.Fatal(err)
	}
	defer ln.Close()

	for {
		// Accept blocks here. It returns a new Conn for the incoming client.
		conn, err := ln.Accept()
		if err != nil {
			log.Println(err)
			continue
		}
		// Handle the connection in a new goroutine so Accept can loop back.
		go handleConn(conn)
	}
}

func handleConn(conn net.Conn) {
	defer conn.Close()
	// Read up to 1KB from the connection.
	buf := make([]byte, 1024)
	n, err := conn.Read(buf)
	if err != nil {
		log.Println(err)
		return
	}
	// Echo the data back to the client.
	conn.Write(buf[:n])
}

How the loop works

The for loop keeps the server alive. Without it, the server handles one client and exits. Accept is a blocking call. It sits there doing nothing until the OS notifies it of a new connection. When Accept returns, you have a fresh net.Conn.

The go keyword is what makes the server concurrent. go handleConn(conn) spawns a new goroutine to process the connection. The main goroutine immediately loops back to Accept. This allows the server to handle thousands of connections simultaneously. Goroutines are cheap; the runtime multiplexes them onto OS threads efficiently.

defer conn.Close() ensures the connection closes when handleConn returns, even if an error occurs. Always close connections. File descriptors are a limited resource. If you leak connections, the OS runs out of descriptors and the server crashes.

The compiler rejects the program with loop variable i captured by func literal if you try to capture a loop variable in a goroutine without care. In this example, conn is a fresh variable from Accept on each iteration, so it's safe. If you were iterating over a slice of connections, you'd need to capture the variable explicitly.

Realistic line-based server

Real servers rarely read raw bytes. They parse protocols. Here's a server that reads lines, handles disconnection, and uses bufio for efficiency.

package main

import (
	"bufio"
	"log"
	"net"
	"strings"
)

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

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

func serveClient(conn net.Conn) {
	defer conn.Close()
	// Wrap conn in a buffered reader for efficient line-based reading.
	reader := bufio.NewReader(conn)

	for {
		// ReadLine returns the line and a token indicating if the read hit EOF.
		line, err := reader.ReadString('\n')
		if err != nil {
			// io.EOF means the client closed the connection gracefully.
			log.Printf("client disconnected: %v", err)
			return
		}
		// Trim the newline character for processing.
		msg := strings.TrimSpace(line)
		if msg == "" {
			continue
		}
		// Echo back with a prefix.
		response := "Server: " + msg + "\n"
		conn.Write([]byte(response))
	}
}

bufio.NewReader wraps the connection and buffers reads. System calls are expensive. Reading one byte at a time triggers a syscall for every byte. bufio reads a chunk of data into memory and serves it up in small pieces, reducing the number of syscalls.

io.EOF is not an error in the sense of failure. It's a signal that the other side closed the connection. When ReadString returns io.EOF, the client is gone. You log it and return. The defer closes the connection, and the goroutine exits.

Go convention accepts verbose error handling. if err != nil { return err } is boilerplate by design. It makes the unhappy path visible. You can't accidentally ignore an error because the compiler forces you to handle it.

Deadlines and timeouts

A slow client can hold a goroutine forever if you don't set deadlines. Imagine a client connects and never sends data. conn.Read blocks indefinitely. The goroutine sits there, consuming memory. If enough clients do this, the server runs out of memory.

Use conn.SetDeadline to limit how long a read or write can block. The deadline is absolute time. When the deadline passes, the operation returns an error.

// Set a 5-second deadline for all operations on this connection.
conn.SetDeadline(time.Now().Add(5 * time.Second))

In production code, you usually derive deadlines from context.Context. context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines.

func serveClient(ctx context.Context, conn net.Conn) {
    // Derive connection deadline from context.
    if dl, ok := ctx.Deadline(); ok {
        conn.SetDeadline(dl)
    }
    // ...
}

Context is plumbing. Run it through every long-lived call site. It lets you propagate cancellation from the top of the call stack down to the network layer.

Pitfalls and errors

Forgetting to close connections leaks file descriptors. The OS has a limit, usually 1024 or 4096 per process. Once you hit the limit, net.Listen or Accept fails with too many open files. Always use defer conn.Close().

Writing to a closed connection returns an error. If you close the connection in one goroutine and try to write in another, you get use of closed network connection. Synchronize access or use channels to coordinate.

Passing the wrong type to Write triggers a compile error. conn.Write expects a []byte. If you pass a string, the compiler complains with cannot use s (variable of type string) as []byte value in argument. Convert with []byte(s).

If the port is already in use, net.Listen returns bind: address already in use. This happens if another process is listening on the same port, or if a previous instance of your server didn't shut down cleanly.

The worst goroutine bug is the one that never logs. If a goroutine blocks forever, it leaves no trace. Use deadlines and contexts to ensure goroutines can be cancelled. Log when connections close, even if it's just io.EOF.

Goroutines are cheap. File descriptors are not. Close every connection.

When to use net.Conn

Use net/http when you are building a web service and want routing, middleware, and JSON handling out of the box.

Use net.Listen and net.Conn when you need a custom binary protocol, low-level control over the TCP stream, or a server that isn't HTTP.

Use net.Dial when your application acts as a client connecting to a remote service.

Use net.Pipe when you need to test network code without binding to a real port or spawning actual goroutines.

Don't reinvent HTTP. Use net/http unless you have a reason not to.

Where to go next