UDP socket buffer sizes are a major performance bottleneck, and tuning them is often the difference between a sluggish connection and one that saturates your network link.

Let’s watch a simple UDP sender and receiver interact, and see how buffer sizes change things.

Imagine a producer sending UDP packets as fast as it can, and a consumer reading them. If the producer is faster than the consumer, packets will start to pile up. Where do they pile up? In the operating system’s socket buffers.

// sender.go
package main

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

func main() {
	addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:8080")
	if err != nil {
		panic(err)
	}

	conn, err := net.DialUDP("udp", nil, addr)
	if err != nil {
		panic(err)
	}
	defer conn.Close()

	fmt.Println("Sending packets...")
	for i := 0; ; i++ {
		_, err := conn.Write([]byte(fmt.Sprintf("Packet %d", i)))
		if err != nil {
			fmt.Fprintf(os.Stderr, "Write error: %v\n", err)
			// In a real scenario, you might want to back off or retry
			time.Sleep(100 * time.Millisecond) // Simple backoff
			continue
		}
		if i%10000 == 0 {
			fmt.Printf("Sent %d packets\n", i)
		}
	}
}
// receiver.go
package main

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

func main() {
	addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:8080")
	if err != nil {
		panic(err)
	}

	conn, err := net.ListenUDP("udp", addr)
	if err != nil {
		panic(err)
	}
	defer conn.Close()

	buffer := make([]byte, 1024) // A small buffer for reading
	fmt.Println("Listening for packets...")

	// Get initial buffer sizes
	rcvBufSize, _ := conn.GetSockOptInt(1, 2) // SOL_SOCKET, SO_RCVBUF
	fmt.Printf("Initial SO_RCVBUF: %d bytes\n", rcvBufSize)

	for {
		n, _, err := conn.ReadFromUDP(buffer)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Read error: %v\n", err)
			continue
		}
		if n > 0 {
			// fmt.Printf("Received: %s\n", buffer[:n]) // Too chatty
		}
		if len(buffer) < n { // This should not happen with proper buffer sizing
			fmt.Fprintf(os.Stderr, "Buffer overflow! Received %d bytes, buffer size %d\n", n, len(buffer))
		}
		if n%10000 == 0 {
			fmt.Printf("Received %d packets\n", n)
		}
	}
}

If you run these two, the receiver will likely start dropping packets very quickly. The sender is just blasting data, and the receiver’s kernel-level buffer (SO_RCVBUF) fills up. Once full, the kernel drops incoming packets. The sender might not even know this is happening because UDP is connectionless and doesn’t provide explicit acknowledgments or retransmissions.

The two key parameters are SO_SNDBUF (send buffer) and SO_RCVBUF (receive buffer). These are kernel-level buffers associated with each socket.

  • SO_RCVBUF: This is the buffer where the kernel stores incoming UDP datagrams after they arrive on the network interface but before your application reads them via ReadFromUDP. If this buffer fills up, incoming datagrams are dropped.
  • SO_SNDBUF: This is the buffer where your application’s data is placed before the kernel sends it out over the network interface. If this buffer fills up, your Write calls will block or return an error (EAGAIN/EWOULDBLOCK if non-blocking, or a full EWOULDBLOCK on UDP).

The default values for these buffers are often quite small, typically a few tens of kilobytes. For high-throughput UDP, this is almost always insufficient.

Tuning SO_RCVBUF

This is usually the more critical one for high-speed UDP reception.

  1. Diagnosis: Run the receiver program. Observe if it reports Read error: read udp 127.0.0.1:8080: i/o timeout or if the n in ReadFromUDP doesn’t increment as rapidly as the sender’s Write calls. You can also check the current buffer size using ss -nu -w 60 | grep 8080 (look for rcv-q and snd-q which are queue lengths, not buffer sizes directly, but high values indicate a problem). A more direct way is to use getsockopt in your application as shown in the receiver example.

  2. Cause: The default SO_RCVBUF is too small for the rate of incoming data.

  3. Fix (Application Level):

    // In receiver.go, before conn.ReadFromUDP
    // Set SO_RCVBUF to 4MB (4 * 1024 * 1024)
    err = conn.SetSockOptInt(1, 2, 4*1024*1024) // SOL_SOCKET, SO_RCVBUF
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error setting SO_RCVBUF: %v\n", err)
        // Handle error appropriately
    }
    rcvBufSize, _ := conn.GetSockOptInt(1, 2) // SOL_SOCKET, SO_RCVBUF
    fmt.Printf("Set SO_RCVBUF to: %d bytes\n", rcvBufSize)
    

    Why it works: This tells the kernel to allocate a larger ring buffer for this specific UDP socket. Incoming datagrams can queue up in this larger buffer, giving your application more time to process them before they are dropped.

  4. Cause: The system-wide maximum for socket buffer sizes is too low. The kernel often has a net.core.rmem_max and net.core.wmem_max sysctl value that caps the actual buffer size, even if you request more.

  5. Fix (System Level):

    # Check current limits
    sysctl net.core.rmem_max
    sysctl net.core.wmem_max
    
    # Set limits to 16MB (example)
    sudo sysctl -w net.core.rmem_max=16777216
    sudo sysctl -w net.core.wmem_max=16777216
    
    # Make persistent (e.g., in /etc/sysctl.conf or a file in /etc/sysctl.d/)
    # echo "net.core.rmem_max=16777216" | sudo tee -a /etc/sysctl.conf
    # echo "net.core.wmem_max=16777216" | sudo tee -a /etc/sysctl.conf
    

    Why it works: These sysctls define the absolute maximum size the kernel will allow for receive and send buffers across all sockets. By increasing them, you permit larger individual socket buffers to be set.

  6. Cause: The default initial send/receive buffer sizes are too small, and the kernel’s automatic tuning mechanism doesn’t grow them large enough.

  7. Fix (System Level):

    # Check default initial sizes
    sysctl net.ipv4.udp_rmem_min
    sysctl net.ipv4.udp_wmem_min
    
    # Increase to 4MB (example)
    sudo sysctl -w net.ipv4.udp_rmem_min=4194304
    sudo sysctl -w net.ipv4.udp_wmem_min=4194304
    
    # Make persistent
    # echo "net.ipv4.udp_rmem_min=4194304" | sudo tee -a /etc/sysctl.conf
    # echo "net.ipv4.udp_wmem_min=4194304" | sudo tee -a /etc/sysctl.conf
    

    Why it works: These parameters set the minimum size for UDP socket buffers. The kernel will attempt to grow them up to net.core.rmem_max (or wmem_max) as needed, but starting with a larger minimum gives them a better chance to reach sufficient size.

Tuning SO_SNDBUF

This is important if your sender is experiencing Write calls blocking or returning errors, or if you want to ensure the sender can keep up with its processing rate.

  1. Diagnosis: Run the sender program. Observe if Write calls return errors like write udp 127.0.0.1:8080: sendto: Resource temporarily unavailable or if the program slows down significantly. You can also check ss -nu -w 60 | grep 8080 for snd-q.

  2. Cause: The default SO_SNDBUF is too small for the rate of outgoing data.

  3. Fix (Application Level):

    // In sender.go, after conn := net.DialUDP(...)
    // Set SO_SNDBUF to 4MB (4 * 1024 * 1024)
    err = conn.SetSockOptInt(1, 2, 4*1024*1024) // SOL_SOCKET, SO_SNDBUF
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error setting SO_SNDBUF: %v\n", err)
        // Handle error appropriately
    }
    // Note: Go's net.DialUDP does not directly expose SetSockOpt for send buffer size.
    // You'd typically need to use a lower-level API or a library that does.
    // For demonstration, if you were using net.ListenUDP for sending (less common for pure sender)
    // or a custom socket implementation, you would apply it there.
    // The principle is the same: use getsockopt/setsockopt.
    

    Why it works: Allocates a larger kernel buffer for outgoing data, allowing the sender application to queue up more data to be sent without blocking.

  4. Cause: System-wide net.core.wmem_max is too low (covered in SO_RCVBUF section).

  5. Fix (System Level): Increase net.core.wmem_max as described above.

  6. Cause: The default initial send buffer size (net.ipv4.udp_wmem_min) is too small.

  7. Fix (System Level): Increase net.ipv4.udp_wmem_min as described above.

The actual buffer sizes you set are often a trade-off. Too small, and you drop packets or block. Too large, and you consume excessive memory, and packets can experience higher latency within the kernel buffers before being processed. A common starting point for high-performance UDP is 4MB to 16MB for both SO_RCVBUF and SO_SNDBUF, but this should be tuned based on your specific network conditions, packet size, and application’s processing speed.

After tuning these buffers, you might encounter the next common issue: the application itself is too slow to process the incoming data, leading to Read calls blocking or the sender’s Write calls blocking.

Want structured learning?

Take the full Udp course →