Socket and Network Libraries in Ruby: Complete Guide to Network Programming with TCPSocket and UDPSocket

Socket and network libraries in Ruby

Network programming is one of those areas where Ruby quietly punches above its weight. Under the hood of every web server, chat application, and API client sits a socket doing the actual work of moving bytes across a wire. Ruby’s standard library ships with a mature, Berkeley-sockets-based networking stack that lets you build everything from a simple TCP echo server to a full UDP-based telemetry system without installing a single gem.

This guide walks through Ruby’s socket libraries from the ground up — starting with what a socket actually is, moving through TCPSocket and UDPSocket in detail, and finishing with performance, memory, and production-readiness concerns that matter once code leaves a tutorial and enters a real system.

What Is a Socket, Really?

A socket is an endpoint for communication between two machines (or two processes on the same machine). When you open a socket in Ruby, you’re really asking the operating system kernel to create a file-descriptor-backed communication channel. Ruby’s Socket class is a thin, object-oriented wrapper around the same system calls a C program would use: socket(), bind(), connect(), listen(), accept(), send(), and recv().

Ruby exposes this functionality through the socket library, which you load with:

require 'socket'

This single require gives you access to a family of classes:

  • TCPSocket — for connection-oriented, reliable, stream-based communication
  • TCPServer — for accepting incoming TCP connections
  • UDPSocket — for connectionless, best-effort datagram communication
  • UNIXSocket / UNIXServer — for local inter-process communication
  • Socket — the low-level base class giving direct access to socket options

Understanding the distinction between TCP and UDP is the foundation everything else builds on.

TCP vs UDP: Choosing the Right Tool

TCP (Transmission Control Protocol) is connection-oriented. Before any data moves, a three-way handshake establishes a reliable, ordered, error-checked stream between two endpoints. If a packet is lost, TCP retransmits it automatically. This reliability comes at the cost of overhead and latency — TCP is ideal for HTTP APIs, database connections, file transfers, and anything where losing or reordering data is unacceptable.

UDP (User Datagram Protocol) is connectionless. Each datagram is fired off independently with no handshake, no guaranteed delivery, and no guaranteed ordering. In exchange, UDP has almost no overhead. It’s the right choice for DNS lookups, video/audio streaming, game state updates, and metrics collection, where a dropped packet just means you wait for the next one rather than stall the whole connection.

Ruby gives you dedicated classes for both, so the choice mostly comes down to what your protocol actually needs.

Working with TCPSocket: Client-Side Networking

TCPSocket is Ruby’s class for making outbound TCP connections. Creating one and connecting to a remote host takes a single line:

require 'socket'

# Connect to a host on a given port
socket = TCPSocket.new('example.com', 80)

# Send a minimal HTTP GET request
socket.write("G ET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")

# Read the full response
response = socket.read
puts response

socket.close

Output (truncated):

HTTP/ 1.1 200 OK
Content-Type: text/html; charset=UTF-8
...

A few things are happening internally that are worth understanding:

  1. TCPSocket.new performs a DNS resolution (if given a hostname), opens a socket file descriptor via the socket() system call, and calls connect() to complete the TCP three-way handshake.
  2. socket.write pushes bytes into the kernel’s send buffer; the actual transmission over the wire happens asynchronously, managed by the OS network stack.
  3. socket.read blocks the current thread until data arrives or the connection is closed by the peer.

Reading Line by Line

Since TCPSocket inherits from IO, you get all the familiar IO methods for free — gets, each_line, read, readpartial, and so on.

require 'socket'

socket = TCPSocket.new('example.com', 80)
socket.write("GE T / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")

while line = socket.gets
  puts line.chomp
end

socket.close

This is important: because TCPSocket is a subclass of IO, anything you already know about file handling in Ruby transfers directly to network handling. That includes buffering behavior, encoding concerns, and the need to always close what you open.

Building a TCP Server with TCPServer

While TCPSocket handles the client side, TCPServer handles the listening/accepting side. Here’s a minimal echo server:

require 'socket'

server = TCPServer.new('127.0.0.1', 4481)
puts "Echo server listening on port 4481..."

loop do
  client = server.accept
  Thread.new(client) do |conn|
    conn.puts "Welcome! Type something and I'll echo it back."
    while line = conn.gets
      conn.puts "Echo: #{line.chomp}"
    end
    conn.close
  end
end

What’s happening internally:

  • TCPServer.new calls socket(), bind(), and listen() under the hood, putting the socket into a passive listening state on the specified address and port.
  • server.accept blocks until a client connects, then returns a new TCPSocket object representing that specific connection. The original listening socket stays open to accept further connections.
  • Wrapping each connection in Thread.new lets the server handle multiple clients concurrently, since accept and gets are both blocking calls that would otherwise serialize every client behind the current one.

You can test this server from another terminal using telnet or nc:

$ nc 127.0.0.1 4481
Welcome! Type something and I'll echo it back.
hello
Echo: hello

A Word on Thread-Per-Connection Servers

The thread-per-connection pattern above is simple and works well for low-to-moderate connection counts, but it doesn’t scale indefinitely. Each Ruby Thread maps to a native OS thread (in MRI/CRuby), and each carries real memory overhead — typically a few hundred KB to a few MB for its stack, depending on OS defaults. Thousands of concurrent idle connections will exhaust memory and put pressure on the OS scheduler long before CPU becomes the bottleneck.

For high-concurrency servers, production Ruby code typically reaches for:

  • IO.select or the Reactor pattern for single-threaded, event-driven I/O multiplexing
  • Fibers combined with a scheduler (Ruby 3.x’s Fiber::Scheduler) for lightweight, cooperative concurrency
  • Battle-tested libraries like EventMachine, Async, or web servers like Puma and Falcon that already solve this problem

Working with UDPSocket

UDP communication in Ruby follows a similar but simpler pattern since there’s no connection to establish or tear down.

UDP Sender

require 'socket'

socket = UDPSocket.new
socket.send("Hello via UDP!", 0, '127.0.0.1', 4482)
socket.close

UDP Receiver

require 'socket'

socket = UDPSocket.new
socket.bind('127.0.0.1', 4482)
puts "UDP listener ready on port 4482..."

loop do
  message, sender_info = socket.recvfrom(1024)
  puts "Received: #{message} from #{sender_info[3]}:#{sender_info[1]}"
end

Output when a message arrives:

UDP listener ready on port 4482...
Received: Hello via UDP! from 127.0.0.1:53211

Notice recvfrom returns both the message and metadata about the sender (sender_info), since — unlike TCP — a UDP socket isn’t bound to a single peer. This is exactly why UDP is well suited to scenarios like a DNS server or a metrics collector that needs to accept messages from many different, unpredictable sources without maintaining per-client state.

Practical Example: A Simple UDP-Based Logger

A common real-world use of UDP is fire-and-forget logging, where an application sends log events to a central collector without waiting for acknowledgment (this is essentially how StatsD works):

require 'socket'
require 'json'

class UDPLogger
  def initialize(host = '127.0.0.1', port = 9999)
    @socket = UDPSocket.new
    @host = host
    @port = port
  end

  def log(event, data = {})
    payload = { event: event, data: data, timestamp: Time.now.to_f }.to_json
    @socket.send(payload, 0, @host, @port)
  end
end

logger = UDPLogger.new
logger.log('user_signup', user_id: 42, plan: 'pro')

Because UDP doesn’t block waiting for a response, this call returns almost instantly, making it safe to sprinkle throughout hot code paths without introducing latency.

Object-Oriented Design Around Sockets

Raw socket code tends to get messy fast if scattered directly through application logic. Idiomatic Ruby wraps socket behavior inside purpose-built classes, leaning on Ruby’s object model.

require 'socket'

class ChatClient
  def initialize(host, port)
    @socket = TCPSocket.new(host, port)
  end

  def send_message(text)
    @socket.puts(text)
  end

  def listen(&block)
    Thread.new do
      while (line = @socket.gets)
        block.call(line.chomp)
      end
    end
  end

  def close
    @socket.close
  end
end

client = ChatClient.new('127.0.0.1', 4481)
client.listen { |msg| puts "Server says: #{msg}" }
client.send_message("hello from the client")
sleep 1
client.close

This design follows encapsulation — the caller never touches the raw TCPSocket directly, which means the underlying transport could later be swapped (say, for a UNIXSocket in tests) without changing any calling code. This is the same principle behind Ruby’s duck typing: as long as an object responds to the right methods, it can stand in for a socket.

Internal Working: Sockets, File Descriptors, and the OS

It helps to understand what’s actually happening beneath Ruby’s abstractions:

  1. File descriptors. Every socket Ruby creates is backed by an OS-level file descriptor, the same kind of integer handle used for open files. This is why TCPSocket and UDPSocket both inherit from IO — at the OS layer, sockets and files are accessed through the same read/write system calls.
  2. Kernel buffers. Data written via write or send doesn’t go directly onto the wire; it’s copied into a kernel-managed send buffer, and the kernel’s network stack handles actual transmission, retransmission (for TCP), and flow control.
  3. Blocking vs non-blocking I/O. By default, socket operations in Ruby block the calling thread until data is available or the buffer has room. Methods like read_nonblock and write_nonblock exist for building non-blocking, event-driven systems, but they require careful handling of IO::WaitReadable and IO::WaitWritable exceptions.
  4. Garbage collection and sockets. A TCPSocket object is a Ruby object like any other, subject to garbage collection. However, GC only reclaims Ruby-level memory — it does not guarantee the underlying file descriptor is closed promptly. Leaving sockets to be closed by the GC’s finalizer is a common source of “too many open files” errors in long-running services. Always close sockets explicitly, ideally with ensure or a block form.
require 'socket'

TCPSocket.open('example.com', 80) do |socket|
  socket.write("GE T / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")
  puts socket.read
end
# socket is automatically closed here, even if an exception occurs

Performance Considerations

  • Buffering. Reading one byte at a time is expensive because each read call is a system call. Prefer reading in reasonably sized chunks (socket.read(4096)) or using buffered methods like gets for line-oriented protocols.
  • Nagle’s Algorithm. TCP by default batches small writes together to reduce packet overhead, which can add latency for latency-sensitive protocols. You can disable this with socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) when you need every write flushed immediately.
  • Thread overhead. As mentioned earlier, thread-per-connection models don’t scale past a few thousand connections. For high-throughput servers, non-blocking I/O with IO.select or a reactor-based gem is the more scalable path.
  • UDP packet size. UDP datagrams larger than the network’s MTU (typically 1500 bytes on Ethernet) get fragmented at the IP layer, which increases the chance of packet loss since losing any one fragment loses the whole datagram. Keeping UDP payloads under roughly 512–1400 bytes is a common safe practice.

Common Mistakes and Debugging Tips

  • Forgetting to close sockets. This leaks file descriptors and will eventually crash a long-running process with Errno::EMFILE. Use TCPSocket.open with a block, or ensure socket.close.
  • Assuming read returns everything at once. TCP is a stream; a single write on one end can arrive as multiple reads on the other, and vice versa. Never assume message boundaries align with individual read/write calls — build your own framing (e.g., newline-delimited messages, or length-prefixed messages) into your protocol.
  • Ignoring Errno::ECONNRESET and Errno::EPIPE. Network connections can drop at any time. Production code should rescue these and handle reconnection or cleanup gracefully rather than letting the process crash.
  • Blocking the whole server on one slow client. In a naive loop without threading, one slow or malicious client can stall every other connection. Always isolate per-connection work.
  • Using UDP where you need reliability. If your application logic assumes messages always arrive and arrive in order, UDP is the wrong transport — you’d be reimplementing TCP badly. Use UDP only when occasional loss is genuinely acceptable.

Debugging network code is easier with the right tools:

# Watch what's happening on a port
$ sudo tcpdump -i lo0 port 4481

# Check what's currently listening
$ lsof -i :4481

# Simple manual TCP testing
$ nc 127.0.0.1 4481

Real-World Applications

  • Custom protocol servers — chat systems, multiplayer game servers, IoT device gateways
  • Health checks and service discovery — lightweight TCP “ping” checks between microservices
  • Log and metrics shipping — UDP-based fire-and-forget telemetry (StatsD-style)
  • Proxying and tunneling — building lightweight TCP proxies for local development
  • Testing HTTP clients/servers — raw sockets are often used in test suites to simulate malformed or edge-case server responses that higher-level HTTP libraries won’t let you construct

Summary

Ruby’s socket library gives you direct, unopinionated access to the same networking primitives used across the industry, wrapped in an object-oriented, IO-compatible interface. TCPSocket and TCPServer cover reliable, connection-oriented communication for anything where message integrity and order matter, while UDPSocket covers lightweight, connectionless communication where speed matters more than guaranteed delivery. Understanding what happens beneath these classes — file descriptors, kernel buffers, blocking behavior, and the cost of thread-per-connection models — is what separates code that works in a demo from code that survives in production. Combine that understanding with disciplined resource cleanup, sensible framing of your protocol, and awareness of where non-blocking I/O becomes necessary, and Ruby is a genuinely capable language for serious network programming.

References

Total
0
Shares

Leave a Reply

Previous Post
Controlling the Thread Scheduler in Ruby

Controlling the Thread Scheduler in Ruby: Thread Management, Fiber, and Concurrency Control Explained

Next Post
how to Install Docker on a Raspberry Pi

How to Install Docker on a Raspberry Pi: Complete Step-by-Step Setup Guide for ARM Devices

Related Posts