Connecting to a TCP Service
You need to talk to a service that doesn't speak HTTP. Maybe a game server, a database on a custom port, or a legacy daemon waiting for a handshake. You have an IP and a port. You need to open a pipe, push data through, and read what comes back. Go's standard library handles the socket plumbing so you don't have to wrestle with file descriptors or blocking modes.
TCP is a reliable byte stream. Think of it like a dedicated phone line. Once the connection is established, data flows both ways in order. You don't worry about packets getting lost or arriving out of sequence. The network layer handles that. In Go, net.Conn is the interface that represents this line. It abstracts away the operating system details. You get a single object that you can read from and write to.
Here's the skeleton: dial a host, wrap the connection for buffered I/O, send a message, flush the buffer, and read the response.
package main
import (
"bufio"
"fmt"
"net"
"os"
)
func main() {
// Connect to the server. "tcp" specifies the protocol.
conn, err := net.Dial("tcp", "localhost:9000")
if err != nil {
fmt.Fprintln(os.Stderr, "failed to connect:", err)
os.Exit(1)
}
// Close the connection when main returns to free resources.
defer conn.Close()
// Wrap the connection for efficient text I/O.
scanner := bufio.NewScanner(conn)
writer := bufio.NewWriter(conn)
// Send a message. Newline signals the end of the line.
_, err = writer.WriteString("ping\n")
if err != nil {
fmt.Fprintln(os.Stderr, "write failed:", err)
return
}
// Push buffered data to the socket immediately.
if err := writer.Flush(); err != nil {
fmt.Fprintln(os.Stderr, "flush failed:", err)
return
}
// Read one line from the server.
if scanner.Scan() {
fmt.Println("server said:", scanner.Text())
}
}
How the connection works
net.Dial does the heavy lifting. It creates a socket, configures it for TCP, and attempts the three-way handshake. If the server isn't listening or the network is down, it returns an error. The success case gives you a net.Conn. This interface defines Read, Write, Close, and a few others. It works for TCP, UDP, and Unix sockets, so your code stays the same if you swap transports later.
Convention aside: defer conn.Close() is the standard pattern. Place it right after the error check. It ensures cleanup happens when the function returns, even if you return early due to an error. The defer statement schedules the call; it doesn't run immediately. This keeps the resource open for the rest of the function while guaranteeing release.
The net.Conn interface is a win for testing. Go follows the mantra "accept interfaces, return structs." net.Dial returns an interface, not a concrete type. You can pass a mock net.Conn to your logic functions. This lets you test client code without spinning up a real server. Trust the interface. Mock it in tests, dial it in production.
Buffering matters for performance. System calls are expensive. Calling conn.Write for every byte triggers a syscall each time. bufio.NewWriter batches writes in memory. You call Flush to push the batch to the socket. For text protocols, bufio.Scanner reads line by line efficiently. It handles the buffering and tokenization for you.
Ah-ha reveal: conn.Write does not guarantee sending all bytes. It returns the number of bytes written and an error. If the error is nil but the count is less than the buffer length, you have a partial write. The compiler won't warn you. If you ignore the count, you lose data. This is different from Python's socket.send wrappers that often handle the loop automatically. bufio.Writer hides this complexity by looping internally until all data is sent or an error occurs. This is another reason to prefer bufio for text protocols.
Adding timeouts and context
Production code needs timeouts and cancellation. Here's how to wire context into the dialer and set a deadline on the connection.
package main
import (
"bufio"
"context"
"fmt"
"net"
"os"
"time"
)
// ConnectWithTimeout dials a TCP address with a deadline.
func ConnectWithTimeout(ctx context.Context, addr string) (net.Conn, error) {
// Dialer respects context cancellation and deadlines.
dialer := net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}
// DialContext integrates with the context for cancellation.
return dialer.DialContext(ctx, "tcp", addr)
}
func main() {
// Create a context with a deadline for the whole operation.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
conn, err := ConnectWithTimeout(ctx, "localhost:9000")
if err != nil {
fmt.Fprintln(os.Stderr, "connection failed:", err)
os.Exit(1)
}
defer conn.Close()
// Set read deadline on the connection for the response.
if err := conn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil {
fmt.Fprintln(os.Stderr, "deadline error:", err)
return
}
scanner := bufio.NewScanner(conn)
writer := bufio.NewWriter(conn)
_, err = writer.WriteString("status\n")
if err != nil {
fmt.Fprintln(os.Stderr, "write error:", err)
return
}
if err := writer.Flush(); err != nil {
fmt.Fprintln(os.Stderr, "flush error:", err)
return
}
if scanner.Scan() {
fmt.Println("response:", scanner.Text())
}
}
net.Dialer lets you configure the connection before dialing. The Timeout field sets the maximum time for the dial to complete. KeepAlive enables TCP keep-alive probes to detect dead connections. DialContext accepts a context.Context. If the context is cancelled, the dial aborts. This is essential for web servers that spawn client connections per request. If the client cancels the request, the dial stops.
Convention aside: context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. This allows callers to control the lifecycle of the operation. The ConnectWithTimeout function follows this pattern.
SetReadDeadline sets a deadline for future reads. If the server doesn't respond in time, the read returns an error instead of blocking forever. This prevents goroutines from hanging indefinitely. You can reset the deadline for each read if you're processing a stream.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and errors
Forgetting Flush is the silent killer. bufio.Writer holds data in memory. If you write and exit without flushing, the server never sees the message. The compiler won't catch this. You just get a timeout on the read. Always flush after writing, or use defer writer.Flush() if the writer lives for the duration of the function.
bufio.Scanner returns false when it hits EOF or an error. You must check scanner.Err() to distinguish between the two. If the server closes the connection cleanly, Scan returns false and Err is nil. If the network drops, Err contains the error. Ignoring Err hides failures.
if scanner.Scan() {
fmt.Println(scanner.Text())
}
if err := scanner.Err(); err != nil {
fmt.Fprintln(os.Stderr, "read error:", err)
}
bufio.Scanner has a default buffer size of 64KB. If the server sends a line longer than that, Scan fails with bufio: token too long. You'd need scanner.Buffer to increase the limit or a custom reader for massive payloads.
net.Dial blocks until connected. Without a timeout, your program hangs forever if the server is down. Always use net.Dialer with a timeout in real code. If you use net.Dial directly, the compiler won't complain, but your runtime will stall.
Common runtime errors include dial tcp 127.0.0.1:9000: connect: connection refused when the server isn't listening, and read tcp 127.0.0.1:12345->127.0.0.1:9000: connection reset by peer when the server closes the connection abruptly. The compiler rejects undefined variables with undefined: pkg and unused imports with imported and not used. These are compile-time checks that keep your code clean.
Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Errors are values in Go. Handle them explicitly. Don't swallow them.
Flush your writer. Check scanner errors. Timeouts save lives.
When to use what
Use net.Dial when you need a quick TCP connection with default settings and the server is local or highly reliable. Use net.Dialer when you must configure timeouts, keep-alives, or local bind addresses before connecting. Use bufio.Scanner and bufio.Writer when working with line-based text protocols to reduce system calls and handle partial writes automatically. Use raw conn.Read and conn.Write when sending binary frames or when you need precise control over byte boundaries. Use context.Context with DialContext when the connection must be cancellable or bound to a request lifecycle. Use net/http when the server speaks HTTP; don't reinvent the protocol over raw TCP.
Use the right tool for the transport. Raw TCP for control, HTTP for standards.