The One-Line Echo Server, and Why io.Copy(conn, conn) Works

Here is a complete, working TCP echo server. Not the interesting part of one, the whole thing:
func handle(conn net.Conn) {
defer conn.Close()
io.Copy(conn, conn)
}
The first time you see io.Copy(conn, conn) it reads like a mistake. You copy a file to another file. You copy a response body to a buffer. But copying something onto itself? That looks like the moral equivalent of x = x, a line that should do nothing at all.
It does quite a lot. It reads every byte a client sends and writes it straight back, forever, until the client goes away. One line, no loop, no buffer you can see, no for. Let us pull it apart, because the reason it works is a small masterclass in how Go's standard library is designed. The exact same trick is sitting underneath your last file download and every reverse proxy you have ever used.
First, what io.Copy actually is
io.Copy has one of the most-quoted signatures in Go:
func Copy(dst Writer, src Reader) (written int64, err error)
It takes a destination you can write to and a source you can read from, and it shovels bytes from one to the other until the source is exhausted. Conceptually it is this loop: a buffer, a read, a write, repeat.
buf := make([]byte, 32*1024)
for {
n, err := src.Read(buf)
if n > 0 {
dst.Write(buf[:n])
}
if err == io.EOF { return } // source done: success
if err != nil { return } // real error
}
The buffer (32 KB by default) is the only memory it uses. It does not load the whole stream into RAM. That detail is why you can io.Copy a 4 GB file through 32 KB of working memory.
Crucially, Copy does not care what dst and src are. It knows only two interfaces:
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
A file, a network socket, an in-memory buffer, an HTTP body, a gzip stream: anything that implements Read is a valid source, and anything that implements Write is a valid destination. They do not have to be the same kind of thing. They do not even have to be different things.
The trick: a socket is both ends of the pipe
Here is the keystone. A network connection in Go is full-duplex. You can send and receive on it at the same time. So net.Conn implements both interfaces at once:
type Conn interface {
Read(b []byte) (n int, err error) // io.Reader: bytes from the client
Write(b []byte) (n int, err error) // io.Writer: bytes to the client
// ... Close, deadlines, addresses
}
Read pulls the bytes the client sent you. Write pushes bytes back to that same client. They are two directions on one wire.
So when you write io.Copy(conn, conn), you hand the same object in as both arguments, but Copy reaches for a different half of it each time:
io.Copy( dst=conn , src=conn )
│ │
.Write() .Read()
to client from client
Copy reads from the client (src.Read) and writes the result back to the client (dst.Write). Read the client, echo to the client. It is not x = x. It is more like holding a phone up to its own speaker: whatever goes in comes right back out.
The plumbing picture is the one that sticks. One pipe, looped back on itself, and that loop-back is the echo:
┌────────────── the same conn ──────────────┐
│ │
▼ │
conn.Read(buf) ──► [ 32 KB buffer ] ──► conn.Write(buf)
When does it stop?
"Forever" needs an asterisk, and it is a nice one. io.Copy returns when the source hits io.EOF. For a socket, EOF means the client closed its sending half. It is done talking. At that point Read returns io.EOF, Copy returns cleanly, your defer conn.Close() fires, and the connection is torn down.
So the lifecycle is entirely driven by the client:
- Client sends
"hello",Readreturns 5 bytes,Writesends"hello"back. - Client waits,
Readblocks (no busy-loop, the goroutine just parks). - Client sends more, it echoes.
- Client closes (or half-closes) the connection,
Readreturnsio.EOF, done.
You can see it without writing a client at all:
$ nc localhost 9000
hello ← you type this
hello ← the server echoes it
^D ← you close the input; the server returns and closes
That blocking Read in step 2 is doing real work for you. It is why a thousand idle connections cost you a thousand cheap, parked goroutines instead of a thousand spinning CPUs.
Why this is good design, not a party trick
The reason one line can be a whole server is that Go's I/O is built from tiny interfaces that compose. Reader and Writer are one method each. Because the echo server, a file copy, and an HTTP download all speak those same two methods, the same io.Copy serves all three:
io.Copy(conn, conn) // echo server: socket to itself
io.Copy(dstFile, srcFile) // cp(1): file to file
io.Copy(os.Stdout, resp.Body) // curl-ish: HTTP body to terminal
io.Copy(gzipWriter, file) // compress on the way out
io.Copy(hash, file) // checksum a file without storing it
Nobody wrote a special "echo" function. Echo fell out of "a socket is a Reader and a Writer" plus "Copy moves Reader to Writer." Small surfaces, combined, give you behavior nobody had to implement. That is the whole Unix-pipes philosophy, expressed as two Go interfaces.
The part that surprises people: it can be zero-copy
Here is where the abstraction pays a bonus. io.Copy checks whether the source implements io.WriterTo, or the destination implements io.ReaderFrom, and if so it lets them do the transfer instead of bouncing bytes through that 32 KB buffer.
For TCP sockets on Linux, this matters a lot. *net.TCPConn implements ReadFrom using the splice(2) system call, which moves data between two file descriptors inside the kernel. The bytes never get copied out to your Go heap and back. So a socket-to-socket io.Copy (an echo, or a proxy hop) can run without your program ever touching the payload. You wrote io.Copy(dst, src), and the kernel did a zero-copy splice.
The same machinery is why io.Copy(file, resp.Body) or serving a static file can hit sendfile(2). The standard library noticed a fast path and took it, behind an interface you were already using. You get kernel-grade performance from the most boring line in your codebase.
Real-world uses you have already relied on
The io.Copy(Writer, Reader) shape is everywhere once you start looking.
A TCP or TLS proxy is two echo servers facing each other. You dial the upstream, then run a copy in each direction:
go io.Copy(upstream, client) // client to upstream
io.Copy(client, upstream) // upstream to client
Two copies, pointed in opposite directions — a proxy is just two echo servers facing each other:
io.Copy(upstream, client)
──────────────────────────────►
client upstream
◄──────────────────────────────
io.Copy(client, upstream)
That is the beating heart of an HTTP CONNECT tunnel, a sidecar proxy, a port-forwarder. Two io.Copy calls and you have forwarded a connection.
A few more places the same shape shows up:
- Downloading a file.
io.Copy(out, resp.Body)streams a response straight to disk in 32 KB sips. No "read it all into a[]bytethen write," the move that OOMs your service on a big file. - Hashing and checksums.
io.Copy(sha256Hasher, file)feeds a file through a hash with constant memory, because a hash is just aWriterthat never forgets. - Compression and encryption mid-stream. Wrap the destination (
gzip.NewWriter, a cipher stream) and the same copy compresses or encrypts on the way out.
Every one of these is the echo server with different ends bolted on. Learn the one line and you have learned all of them.
The fine print, so you ship it correctly
io.Copy(conn, conn) is elegant, but a production handler usually wants a little more around it:
- Run it in a goroutine, and check the error you currently ignore.
io.Copyreturns(bytesCopied, err). The echo server throws both away, which is fine for a toy. A real server logs the error and accounts for the bytes. - Set a deadline. A client that connects and says nothing will keep a goroutine parked forever.
conn.SetDeadline(time.Now().Add(idleTimeout))turns "blocks forever" into "blocks until idle," soReadeventually errors out andCopyreturns. - Mind half-close. EOF means the client stopped sending. It can still be receiving. For a pure echo that is exactly what you want. For protocols with their own framing, you will outgrow raw
io.Copyand parse messages yourself.
None of that changes the core, though. The core is still one line, and it still works for the same reason: a socket is a Reader and a Writer, and Copy moves bytes from one to the other.
The takeaway
io.Copy(conn, conn) is not a clever hack bolted onto the language. It is what you get for free when your standard library is built from one-method interfaces that compose:
- a connection is both a
Readerand aWriter, Copyneeds only one of each,- so reading-a-thing and writing-the-same-thing is a complete, correct echo server,
- and the kernel will even make it zero-copy without being asked.
Next time a single line of someone else's code looks like it should do nothing, check whether the same value is quietly playing two roles. Often that is not a typo. It is the design working.
Build it yourself. Solve the TCP Echo Server challenge on barehands and get graded on correctness and speed. No libraries, just the standard toolchain.