How to Create a TCP Server in Go

Web
Create a TCP server in Go using the net package to listen on a port and handle incoming connections.

The pipe behind the web

You've built a web server. It accepts a request, processes data, returns a response, and closes the door. That model works for APIs and static sites. It falls apart when you need a chat room, a multiplayer game loop, or a streaming data feed. HTTP is request-response. The client knocks, the server answers, and the connection drops. TCP is different. It's a persistent pipe. Once the connection is established, data flows both ways until someone pulls the plug.

Building a TCP server in Go doesn't require a third-party library. The standard library provides the primitives: a listener to wait for connections, a connection object to read and write bytes, and goroutines to handle concurrency. You get the plumbing without a framework dictating your protocol.

How TCP works in Go

Think of HTTP like sending a letter. You write it, drop it in the mailbox, and wait for a reply. The exchange ends. TCP is like a phone call. You dial, someone answers, and the line stays open. You can talk back and forth as long as you want.

In Go, net.Listen creates the phone number. It reserves a port and asks the operating system to queue incoming calls. Your program sits in a loop calling Accept. This blocks until a client connects. When a client knocks, Accept returns a net.Conn. This object represents the open line. You read from it and write to it. The connection stays alive until you close it or the network fails.

Go treats net.Conn as an interface, not a concrete type. This matters for testing. You can pass a mock connection to your handler functions without spinning up real sockets. The interface defines Read, Write, Close, and a few others. If it implements those methods, it's a connection.

Minimal echo server

Here's the skeleton of a TCP server. It listens on a port, accepts connections, and spawns a goroutine for each one.

package main

import (
	"io"
	"log"
	"net"
)

func main() {
	// Listen on port 8080 for TCP connections across all network interfaces.
	ln, err := net.Listen("tcp", ":8080")
	if err != nil {
		log.Fatal(err)
	}
	// Close the listener when main exits to free the port.
	defer ln.Close()

	for {
		// Block until a client connects, then return a dedicated connection object.
		conn, err := ln.Accept()
		if err != nil {
			log.Println("accept error:", err)
			continue
		}
		// Spawn a goroutine so the loop can accept the next client immediately.
		go handleConnection(conn)
	}
}

The main function sets up the listener and enters an infinite loop. ln.Accept() does the heavy lifting. It waits for a connection. When one arrives, it returns a net.Conn and an error. If the error is nil, you have a live connection. You pass it to handleConnection and loop back to Accept. This keeps the server responsive. If you didn't use go, the server would handle one client at a time. The next client would wait until the first one finished.

func handleConnection(conn net.Conn) {
	// Ensure the connection closes when this function returns.
	defer conn.Close()
	// Echo all received data back to the sender until the connection closes.
	io.Copy(conn, conn)
}

The handler uses defer to close the connection when the function exits. This prevents resource leaks. io.Copy reads bytes from the connection and writes them back. It runs until the client closes the connection or an error occurs. The server echoes everything.

Goroutines are cheap. Connections are not.

What happens under the hood

When you run this code, net.Listen makes a system call to create a socket and bind it to port 8080. The OS starts listening for SYN packets. When a client connects, the OS completes the handshake and adds the connection to a queue. Your program is blocked in Accept. The OS wakes up the goroutine and returns the connection.

io.Copy uses a buffer to move data. It reads chunks from the source, writes them to the destination, and repeats. It handles partial reads and writes automatically. If the client sends 10 megabytes, io.Copy streams it back in chunks. It doesn't load everything into memory at once.

The defer conn.Close() is critical. Every connection consumes file descriptors and memory. If you forget to close the connection, the goroutine might exit, but the socket stays open. Over time, you'll exhaust file descriptors and the server will crash. Always close the connection.

Close the connection or leak the goroutine.

Realistic handler with timeouts

Real servers need more than echo. Clients drop connections. Networks fail. You need timeouts and structured protocols. Here's a handler that reads lines, checks for cancellation, and enforces a deadline.

package main

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

// handleConnection processes a client with a timeout and line-based protocol.
func handleConnection(ctx context.Context, conn net.Conn) {
	defer conn.Close()
	// Prevent idle connections from leaking resources indefinitely.
	conn.SetDeadline(time.Now().Add(5 * time.Minute))

	reader := bufio.NewReader(conn)
	for {
		// Read until newline; returns io.EOF when the client disconnects.
		line, err := reader.ReadString('\n')
		if err != nil {
			return
		}

The function signature takes context.Context as the first parameter. This is a Go convention. Contexts carry deadlines, cancellation signals, and request-scoped values. Functions that take a context should respect it. SetDeadline sets a timer on the socket. If no data arrives within five minutes, the connection times out. This stops zombie connections from consuming memory.

bufio.NewReader wraps the connection. Reading one byte at a time triggers a system call for every byte. bufio reads larger chunks into memory and serves them to your code. It reduces system calls and improves performance.

		// Respect server shutdown signals from the context.
		if ctx.Err() != nil {
			return
		}

		// Write response back to the client.
		fmt.Fprintf(conn, "Echo: %s", line)
	}
}

The loop checks ctx.Err() after reading. If the context is cancelled, the function returns. This allows a graceful shutdown. When the server receives a signal, it cancels the context, and all handlers exit. fmt.Fprintf writes the response. It handles the conversion and writes to the connection.

Context is plumbing. Run it through every long-lived call site.

Pitfalls and errors

TCP is unreliable at the application level. Clients vanish. Networks partition. You'll see errors in production. Handle them calmly.

If a client kills the connection abruptly, Read returns an error like read tcp 127.0.0.1:8080: connection reset by peer. This is normal. Log it and close the connection. Don't panic. If you write to a closed socket, you get write tcp ...: broken pipe. This means the other side is gone. Drop the write and exit.

The compiler enforces error handling. If you ignore the error from Accept, you get err declared and not used. Go forces you to acknowledge errors. The community accepts the if err != nil boilerplate because it makes the unhappy path visible. Don't fight it.

Goroutine leaks are the worst bug. If you spawn a goroutine per connection but the goroutine blocks forever, you leak memory. Always have a cancellation path. Use context or SetDeadline. If a goroutine waits on a channel that never gets closed, it hangs. Design your channels carefully.

TCP is a stream. You define the messages.

When to use TCP

Pick the right tool for the job. Raw TCP gives you control but requires more work.

Use a raw TCP server when you need a custom binary protocol or long-lived bidirectional streams where HTTP overhead is unacceptable. Use an HTTP server when your data fits request-response patterns and you want built-in routing, middleware, and browser compatibility. Use WebSockets when you need full-duplex communication with clients that must run in a browser. Use gRPC when you want structured data exchange with automatic code generation and strong typing. Use plain sequential code when you only expect one client at a time, though this is rare in production.

Trust the standard library. It handles the hard parts.

Where to go next