Socket Programming in Ruby: TCP, UDP, and Network Clients
Socket programming in Ruby lets you build network applications that communicate over TCP, UDP, or Unix sockets. Ruby’s socket library provides both low-level and high-level interfaces, from the core Socket class to convenient wrappers like TCPServer and TCPSocket.
When to use sockets
Use Ruby’s socket classes when you need:
- Custom network protocols
- Low-level network control
- UDP datagram communication
- Unix domain sockets for local IPC
For HTTP clients, prefer Net::HTTP. For HTTP servers, consider WEBrick or frameworks like Rails. Sockets give you direct control over the wire protocol.
Core classes overview
| Class | Purpose |
|---|---|
Socket | Low-level, supports any protocol family |
TCPServer | TCP server sockets |
TCPSocket | TCP client sockets |
UDPSocket | UDP datagram sockets |
UNIXServer | Unix domain server sockets |
UNIXSocket | Unix domain client sockets |
TCP Server
Basic TCP Server
require 'socket'
server = TCPServer.new(8080)
loop do
client = server.accept
request = client.gets
client.puts "HTTP/1.1 200 OK\r\n\r\nHello, World!"
client.close
end
TCPServer.new(port) creates a server socket bound to the specified port on all available network interfaces. This means the server accepts connections from any machine that can reach your host, which is fine for development but may not be what you want for a production service. For a specific interface, pass the hostname:
server = TCPServer.new('127.0.0.1', 8080)
Accept returns a TCPSocket
The accept method returns a TCPSocket representing the connected client. This socket is bidirectional—you can read and write to it. Once you have a client socket, it behaves like any Ruby IO object: call gets to read a line the client sent and puts to send a response back down the same connection. The socket stays open until you explicitly close it, so you control exactly when the connection ends:
client = server.accept
client.puts "Welcome!"
response = client.gets
client.close
Handling multiple clients
To handle multiple clients concurrently, use threads. When the server accepts a connection inside the loop, it calls Thread.start and passes the client socket into the new thread. The thread then enters its own read loop, processing lines from that specific client until the client disconnects. Meanwhile the main thread loops back to accept and waits for the next incoming connection, so no single client can stall the server:
require 'socket'
server = TCPServer.new(8080)
loop do
client = server.accept
Thread.start(client) do |conn|
loop do
line = conn.gets
break if line.nil?
conn.puts "Echo: #{line}"
end
conn.close
end
end
TCP Client
Connecting to a Server
Just as TCPServer handles the server side of a TCP connection, TCPSocket is the client counterpart. A TCPSocket instance connects to a remote server and provides the same bidirectional IO interface: you can send data with puts or write and receive responses with gets or read. The constructor takes a hostname and a port number, and the connection attempt blocks until it either succeeds or raises an exception.
require 'socket'
client = TCPSocket.new('localhost', 8080)
client.puts "Hello, server!"
response = client.gets
client.close
TCPSocket.new(hostname, port) connects to the specified server. The connection is blocking, so execution pauses until the connection is established or fails. This is usually fine for simple scripts, but if the remote host is slow or unreachable your program will hang until the operating system TCP timeout fires, which can take a minute or more.
Non-Blocking Connect
For non-blocking behavior, use TCPSocket.open with a block and handle IO::WaitWritable:
require 'socket'
begin
socket = TCPSocket.new('slow-server', 80)
rescue IO::WaitWritable
IO.select(nil, [socket], nil, 5) # Wait up to 5 seconds
begin
socket.connect_nonblock(socket.remote_address)
rescue IO::WaitReadable
# Connection failed
end
end
UDP Sockets
Unlike TCP, UDP does not establish a persistent connection between the client and server. Instead, each datagram is an independent packet that carries its own destination address. This makes UDP lightweight and fast, though it does not guarantee delivery or ordering. Create a UDPSocket to send and receive these datagrams. To receive data, bind the socket to a local address and port, then call recvfrom which returns both the message payload and an Addrinfo object describing the sender. Use send to reply back to that sender:
require 'socket'
socket = UDPSocket.new
socket.bind('127.0.0.1', 8080)
message, sender = socket.recvfrom(1024)
puts "Received: #{message} from #{sender.ip_address}"
socket.send("ACK", 0, sender.ip_address, sender.ip_port)
When you only need to send UDP datagrams and do not care about receiving replies, you can skip the bind step entirely. The operating system assigns a temporary source port automatically when you call send. This pattern is common for fire-and-forget telemetry, logging, or simple alert pings where acknowledgement is not required. For sending without binding:
socket = UDPSocket.new
socket.send("Hello", 0, '127.0.0.1', 8081)
Low-level socket class
The high-level classes like TCPServer and UDPSocket cover the most common use cases, but sometimes you need to specify a non-default protocol family or set socket options before binding. The base Socket class exposes every knob the operating system provides. You choose the address family with constants like Socket::AF_INET for IPv4 and the socket type with Socket::SOCK_STREAM for TCP or Socket::SOCK_DGRAM for UDP. After creating the raw socket, pack the address into a binary sockaddr structure, bind to it, and then call listen to start accepting connections:
require 'socket'
# Create an IPv4 TCP socket
socket = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
# Bind to a specific address and port
address = Socket.pack_sockaddr_in(8080, '0.0.0.0')
socket.bind(address)
# Start listening
socket.listen(5)
# Accept a connection
client, client_addr = socket.accept
Socket address packing
Socket.pack_sockaddr_in(port, hostname) packs a port and hostname into a sockaddr structure required by low-level socket calls. This function handles the internal binary layout that the operating system expects, so you do not need to work with raw address structures directly. The same packed address can be passed to bind to set the local address or to connect to specify a remote peer. A companion function, Socket.unpack_sockaddr_in, reverses the process and extracts the port and IP from a packed address:
addr = Socket.pack_sockaddr_in(80, 'example.com')
socket.connect(addr)
Common Gotchas
1. Forgetting to close sockets
Sockets are file descriptors. Unclosed sockets leak file descriptors and may keep connections alive unexpectedly. Each process has a finite number of file descriptors, and once you exhaust that limit the operating system refuses to open new sockets or even new files. Beyond the resource leak, an unclosed socket can hold a TCP connection in a half-open state on the remote side, preventing the peer from cleaning up its own resources. Always close sockets, preferably with blocks:
TCPServer.new(8080) do |server|
client = server.accept
begin
# Work with client
ensure
client.close # Always close
end
end
2. Blocking I/O
All socket operations are blocking by default. In a threaded server, this is fine. For event-driven or async servers, use non-blocking methods:
accept_nonblockconnect_nonblockread_nonblock/write_nonblock
3. Socket Options
Default socket options may not suit your needs. Set options before binding or connecting:
socket = TCPSocket.new
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
Common options:
SO_REUSEADDR: allows binding to a port in TIME_WAIT stateSO_REUSEPORT: allows multiple sockets on the same port (Linux 3.9+)TCP_NODELAY: disables Nagle’s algorithm for low-latency
4. Address family confusion
Different address families use different address formats. AF_INET is IPv4, AF_INET6 is IPv6. Mixing them causes errors:
# IPv4 only
socket = Socket.new(AF_INET, SOCK_STREAM)
# IPv6 only
socket = Socket.new(AF_INET6, SOCK_STREAM)
5. Reading Until EOF
gets reads until a newline. If the server does not send a newline, gets blocks indefinitely. For binary protocols or custom framing where newlines are not part of the wire format, you need a different approach. Use read with an exact byte count when you know the expected message size ahead of time, or use readpartial to grab whatever data is available without blocking. The readpartial method works especially well inside an IO.select loop for multiplexed I/O. The examples below show both patterns:
# Read exactly 100 bytes
data = client.read(100)
# Read up to 1024 bytes (non-blocking)
data = client.read_nonblock(1024)
TCP versus UDP
TCP is the right choice when you need ordered delivery and a stable connection. That makes it a good fit for request-response systems, chat servers, and any application where lost packets would be a problem. The trade-off is that TCP has more overhead because it keeps the connection state and acknowledges delivery.
UDP is a better fit when speed matters more than reliability. Real-time telemetry, discovery protocols, and simple broadcast-style messages often use UDP because they can tolerate the occasional dropped packet. If your application needs both speed and reliability, you usually need to build that logic yourself on top of UDP.
Common mistakes
One common mistake is forgetting that most socket calls block by default. If your server handles more than one client, you need threads, non-blocking I/O, or another concurrency strategy. Otherwise one slow client can stall the whole process.
Another mistake is assuming sockets clean themselves up automatically. They do not. Always close client sockets and server sockets when you are done with them, or wrap the code in ensure blocks so cleanup still happens on error.
It is also easy to mix address families. An IPv4 socket uses AF_INET, while an IPv6 socket uses AF_INET6. Matching the wrong family to the wrong address format is a fast path to confusing errors.
Frequently asked questions
Do I need sockets for HTTP?
Usually not. If you are making ordinary web requests, use Net::HTTP or a higher-level client. Sockets are for cases where you need protocol control, custom framing, or a network service that is not HTTP.
Should I use threads with sockets?
Often yes. A simple threaded server is a good first step because each connection can be handled independently. If you need very high concurrency, you may eventually move to non-blocking I/O or an evented design.
When should I use Unix domain sockets?
Use them when both processes live on the same machine and you want faster local IPC than TCP. They are common for database proxies, local daemons, and other internal services.
Conclusion
Ruby’s socket classes are low-level, but that is also what makes them flexible. They let you build exactly the protocol you need, whether that is a tiny local service, a custom network client, or a server that handles raw TCP connections.
Start with the highest-level socket class that fits your needs, close connections carefully, and keep the concurrency model simple. If you find yourself reimplementing HTTP or another well-known protocol, step back and use a higher-level library instead.
That is the main lesson of socket programming in Ruby: start small, keep the protocol explicit, and add complexity only when the simpler shape stops fitting the problem.
A small request-response loop
If you are learning sockets, it helps to keep the first protocol extremely small. A simple line-based request and response is enough to show how the connection opens, how data moves, and when the socket closes. Once that is clear, you can add framing, binary payloads, or a richer message format.
require 'socket'
server = TCPServer.new(4040)
loop do
client = server.accept
request = client.gets
client.puts "You sent: #{request}"
client.close
end
This tiny loop is not production-ready, but it is a good learning tool because every step is visible. A client connects, the server reads one line, and the server replies before closing the connection. That is the core rhythm of many socket protocols, even when the real code later adds threads, framing, or authentication.
Ruby version requirements
All socket classes work with Ruby 2.7+. Ruby 3.0+ includes frozen string literals and keyword argument changes. The socket API has been stable across Ruby versions.
Summary
- Use
TCPServer/TCPSocketfor simple TCP client-server applications - Use
UDPSocketfor connectionless datagram communication - Use
Socketfor low-level control over protocols and socket options - Always close sockets to prevent resource leaks
- Use threads or non-blocking I/O for concurrent servers
- Set socket options like
SO_REUSEADDRbeforebindorlisten
See also
- Making HTTP requests in Ruby — the next layer up the network stack; when raw sockets are too low-level
- WebSockets in Ruby — persistent bidirectional channels built on top of TCP
- HTTP clients in Ruby — compare Net::HTTP, Faraday, and HTTParty for higher-level networking