UDP is actually faster than TCP for a reason: it doesn’t bother to guarantee delivery.

Let’s see this in action. Imagine we have two simple Go programs. The server listens on a UDP port, and the client sends a bunch of messages.

UDP Server (udp_server.go)

package main

import (
	"fmt"
	"net"
	"os"
	"time"
)

func main() {
	port := ":8080"
	addr, err := net.ResolveUDPAddr("udp", port)
	if err != nil {
		fmt.Println("Error resolving UDP address:", err)
		os.Exit(1)
	}

	conn, err := net.ListenUDP("udp", addr)
	if err != nil {
		fmt.Println("Error listening on UDP:", err)
		os.Exit(1)
	}
	defer conn.Close()

	fmt.Printf("UDP Server listening on %s\n", port)

	buffer := make([]byte, 1024)
	for {
		n, remoteAddr, err := conn.ReadFromUDP(buffer)
		if err != nil {
			fmt.Println("Error reading from UDP:", err)
			continue
		}
		message := string(buffer[:n])
		fmt.Printf("Received from %s: %s\n", remoteAddr, message)
	}
}

UDP Client (udp_client.go)

package main

import (
	"fmt"
	"net"
	"os"
	"time"
)

func main() {
	serverAddr, err := net.ResolveUDPAddr("udp", "localhost:8080")
	if err != nil {
		fmt.Println("Error resolving server address:", err)
		os.Exit(1)
	}

	conn, err := net.DialUDP("udp", nil, serverAddr)
	if err != nil {
		fmt.Println("Error dialing UDP:", err)
		os.Exit(1)
	}
	defer conn.Close()

	for i := 0; i < 20; i++ {
		message := fmt.Sprintf("Message %d", i)
		_, err := conn.Write([]byte(message))
		if err != nil {
			fmt.Println("Error sending UDP message:", err)
			// UDP doesn't guarantee delivery, so we might not even get an error here
		}
		fmt.Printf("Sent: %s\n", message)
		time.Sleep(100 * time.Millisecond) // Small delay to make it observable
	}
	fmt.Println("Finished sending UDP messages.")
}

If you run the server and then the client, you’ll see the server output most of the messages. But if you introduce packet loss (e.g., by using a network simulator or just having a flaky connection), the server simply won’t report receiving some messages. There’s no built-in retry mechanism.

Now, let’s look at TCP. TCP is all about reliable, ordered delivery. It achieves this through a handshake, acknowledgments, sequence numbers, and retransmissions.

TCP Server (tcp_server.go)

package main

import (
	"fmt"
	"net"
	"os"
	"time"
)

func main() {
	port := ":8080"
	listener, err := net.Listen("tcp", port)
	if err != nil {
		fmt.Println("Error listening on TCP:", err)
		os.Exit(1)
	}
	defer listener.Close()

	fmt.Printf("TCP Server listening on %s\n", port)

	for {
		conn, err := listener.Accept()
		if err != nil {
			fmt.Println("Error accepting connection:", err)
			continue
		}
		go handleConnection(conn)
	}
}

func handleConnection(conn net.Conn) {
	defer conn.Close()
	fmt.Printf("Accepted connection from %s\n", conn.RemoteAddr())

	buffer := make([]byte, 1024)
	for {
		n, err := conn.Read(buffer)
		if err != nil {
			if err.Error() == "EOF" {
				fmt.Printf("Connection from %s closed.\n", conn.RemoteAddr())
			} else {
				fmt.Printf("Error reading from %s: %v\n", conn.RemoteAddr(), err)
			}
			return
		}
		message := string(buffer[:n])
		fmt.Printf("Received from %s: %s\n", conn.RemoteAddr(), message)
	}
}

TCP Client (tcp_client.go)

package main

import (
	"fmt"
	"net"
	"os"
	"time"
)

func main() {
	serverAddr := "localhost:8080"

	conn, err := net.Dial("tcp", serverAddr)
	if err != nil {
		fmt.Println("Error dialing TCP:", err)
		os.Exit(1)
	}
	defer conn.Close()

	fmt.Printf("Connected to %s\n", serverAddr)

	for i := 0; i < 20; i++ {
		message := fmt.Sprintf("Message %d", i)
		_, err := conn.Write([]byte(message))
		if err != nil {
			fmt.Println("Error sending TCP message:", err)
			// TCP will likely return an error if delivery fails significantly
		}
		fmt.Printf("Sent: %s\n", message)
		time.Sleep(100 * time.Millisecond)
	}
	fmt.Println("Finished sending TCP messages.")
}

When you run the TCP client and server, you’ll notice a few things. First, there’s a connection setup phase (the TCP handshake) before any data is sent. Second, the TCP server will report receiving all messages, even if you simulate packet loss. TCP’s internal mechanisms ensure that missing packets are retransmitted until they are successfully received and acknowledged by the other side.

The core difference lies in their design philosophy. UDP is a "fire and forget" protocol. It’s like sending a postcard – fast, simple, but no guarantee it arrives. TCP, on the other hand, is like registered mail. It’s slower, involves more overhead (connection setup, acknowledgments, sequence numbers), but you get confirmation that your message arrived, and in the right order.

This leads to the fundamental trade-off: reliability vs. latency.

  • UDP:

    • Pros: Lower latency, less overhead, good for applications where occasional packet loss is acceptable or handled at the application layer.
    • Cons: Unreliable, unordered delivery.
    • Use Cases: Streaming media (video, audio), online gaming, DNS, VoIP. If a frame of video or a packet of voice data is lost, the application can often cope by interpolating or simply skipping it.
  • TCP:

    • Pros: Reliable, ordered delivery, built-in flow control and congestion control.
    • Cons: Higher latency, more overhead due to acknowledgments and retransmissions.
    • Use Cases: Web browsing (HTTP/HTTPS), email (SMTP/IMAP), file transfer (FTP). Losing a byte of a webpage or an email is generally unacceptable.

The hidden complexity in TCP is its congestion control algorithm. When a TCP connection experiences packet loss, it interprets this as network congestion and reduces its sending rate. This is crucial for the stability of the internet, preventing a single application from overwhelming the network, but it means that TCP’s performance can degrade significantly under heavy load or poor network conditions, even if individual packets are being delivered.

When you’re deciding between TCP and UDP, think about what happens if data doesn’t arrive. If your application can tolerate missing pieces or reassemble them itself, UDP might be a better fit for speed. If every piece of data must arrive and in the correct sequence, TCP is your only choice.

The next logical step is to explore how application-layer protocols like HTTP/2 leverage UDP (via QUIC) to overcome some of TCP’s limitations while still aiming for reliability.

Want structured learning?

Take the full Tcp course →