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 communicationTCPServer— for accepting incoming TCP connectionsUDPSocket— for connectionless, best-effort datagram communicationUNIXSocket/UNIXServer— for local inter-process communicationSocket— 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:
TCPSocket.newperforms a DNS resolution (if given a hostname), opens a socket file descriptor via thesocket()system call, and callsconnect()to complete the TCP three-way handshake.socket.writepushes bytes into the kernel’s send buffer; the actual transmission over the wire happens asynchronously, managed by the OS network stack.socket.readblocks 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.newcallssocket(),bind(), andlisten()under the hood, putting the socket into a passive listening state on the specified address and port.server.acceptblocks until a client connects, then returns a newTCPSocketobject representing that specific connection. The original listening socket stays open to accept further connections.- Wrapping each connection in
Thread.newlets the server handle multiple clients concurrently, sinceacceptandgetsare 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.selector theReactorpattern 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:
- 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
TCPSocketandUDPSocketboth inherit fromIO— at the OS layer, sockets and files are accessed through the same read/write system calls. - Kernel buffers. Data written via
writeorsenddoesn’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. - 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_nonblockandwrite_nonblockexist for building non-blocking, event-driven systems, but they require careful handling ofIO::WaitReadableandIO::WaitWritableexceptions. - Garbage collection and sockets. A
TCPSocketobject 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 withensureor 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
readcall is a system call. Prefer reading in reasonably sized chunks (socket.read(4096)) or using buffered methods likegetsfor 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.selector 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. UseTCPSocket.openwith a block, orensure socket.close. - Assuming
readreturns everything at once. TCP is a stream; a singlewriteon one end can arrive as multiplereads 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::ECONNRESETandErrno::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.
