Sockets #
A socket is the fundamental abstraction for network communication — an endpoint of a connection where two programs can exchange data across a network or even within the same machine. Ruby provides the socket library in its standard library that wraps POSIX socket system calls with a more Ruby-like interface. But beyond syntax, building a correct socket server requires understanding how to handle many clients concurrently, how to frame messages so they don’t get truncated, how to handle dropped connections cleanly, and how to secure communication with SSL/TLS. This article covers all of this, from basic TCP to a server ready for real-world load.
TCP vs UDP — Choosing the Right Protocol #
Before writing any code, understanding the fundamental differences between TCP and UDP is a foundation you can’t skip:
TCP (Transmission Control Protocol):
✓ Connection-oriented — there's a handshake before data flows
✓ Reliable — data is guaranteed to arrive, in the right order
✓ Built-in flow control and congestion control
✓ Stream-based — data flows like a river, not separate packets
✗ Higher overhead due to reliability mechanisms
✗ Higher latency due to handshake and retransmission
Suitable for: web servers, databases, file transfer, email, chat
UDP (User Datagram Protocol):
✓ Connectionless — send directly without a handshake
✓ Faster with lower latency
✓ Datagram-based — each send is a separate packet
✗ Not reliable — packets can be lost, duplicated, or out of order
✗ The application must handle reliability itself if needed
Suitable for: video/audio streaming, online games, DNS, VoIP
sequenceDiagram
participant C as Client
participant S as Server
note over C,S: TCP — Three-way Handshake
C->>S: SYN
S->>C: SYN-ACK
C->>S: ACK
note over C,S: Connection established
C->>S: Data
S->>C: ACK
S->>C: Response
C->>S: ACK
C->>S: FIN
S->>C: FIN-ACK
note over C,S: UDP — No Handshake
C->>S: Datagram (direct)
S->>C: Datagram (direct, or not — no guarantees)Basic TCP Server #
TCPServer is the easiest wrapper for creating a TCP server in Ruby:
require 'socket'
# Create a server listening on port 4000
server = TCPServer.new("localhost", 4000)
puts "Server running on localhost:4000"
# Loop accepting connections (blocking — waits until a client arrives)
loop do
client = server.accept # wait for an incoming connection
puts "Client connected from #{client.peeraddr[2]}:#{client.peeraddr[1]}"
# Send a response
client.puts "Welcome! The time is now: #{Time.now}"
client.puts "Type something and press Enter:"
# Receive a message from the client
message = client.gets&.chomp
client.puts "You typed: #{message}"
client.close # close the connection with this client
puts "Client disconnected"
end
To test this server without writing a client first, use nc (netcat) from the terminal:
# Terminal 1: run the server
ruby server.rb
# Terminal 2: connect using netcat
nc localhost 4000
TCP Client #
require 'socket'
begin
# Create a connection to the server
socket = TCPSocket.new("localhost", 4000)
puts "Connected to the server"
# Receive the welcome message
welcome = socket.gets
puts "Server: #{welcome.chomp}"
prompt = socket.gets
puts "Server: #{prompt.chomp}"
# Send a message
socket.puts "Hello from the client!"
# Receive the reply
reply = socket.gets
puts "Server: #{reply.chomp}"
rescue Errno::ECONNREFUSED => e
puts "Connection failed: the server might not be running"
rescue Errno::ETIMEDOUT => e
puts "Connection timed out"
ensure
socket&.close
puts "Connection closed"
end
Multi-Client Server with Threads #
The basic server above can only serve one client at a time — the next client has to wait until the first one finishes. For a real server, each client should be handled in its own thread:
require 'socket'
class EchoServer
def initialize(host, port)
@server = TCPServer.new(host, port)
@active_clients = []
@mutex = Mutex.new
puts "Echo server running on #{host}:#{port}"
end
def run
loop do
client = @server.accept
Thread.new(client) { |conn| handle_client(conn) }
end
rescue Interrupt
cleanup
end
private
def handle_client(conn)
addr = conn.peeraddr
puts "Client connected: #{addr[2]}:#{addr[1]}"
@mutex.synchronize { @active_clients << conn }
conn.puts "Welcome to the Echo Server!"
conn.puts "Type 'exit' to disconnect."
loop do
line = conn.gets
break if line.nil? # connection closed by the client
line.chomp!
break if line.downcase == "exit"
puts "[#{addr[2]}] #{line}"
conn.puts "Echo: #{line}"
end
rescue Errno::ECONNRESET, Errno::EPIPE => e
# Client forcibly disconnected
puts "Connection reset by client: #{addr[2]}"
ensure
@mutex.synchronize { @active_clients.delete(conn) }
conn.close rescue nil
puts "Client disconnected: #{addr[2]}"
end
def cleanup
puts "\nShutting down the server..."
@mutex.synchronize do
@active_clients.each do |conn|
conn.puts "The server is shutting down..."
conn.close rescue nil
end
end
@server.close
puts "Server finished."
end
end
server = EchoServer.new("0.0.0.0", 4000)
server.run
SO_REUSEADDR — Restart the Server Without Waiting #
When a server is stopped and restarted quickly, the port may still be in TIME_WAIT status from previous connections. SO_REUSEADDR fixes this:
require 'socket'
server = TCPServer.new("localhost", 4000)
# Enable SO_REUSEADDR — allow reusing a recently used port
server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
# Or the more idiomatic way:
server = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0)
server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
server.bind(Addrinfo.tcp("localhost", 4000))
server.listen(Socket::SOMAXCONN)
Message Framing — The Often Overlooked Problem #
TCP is a stream protocol — there’s no natural boundary between one “message” and the next. Data sent with a single write isn’t necessarily read with a single read on the other side. This is called the framing problem and is a common source of bugs:
# ANTI-PATTERN: assuming one send = one recv
# Server
client.write("first message") # could be truncated mid-way!
# Client
data = client.read(1024) # could get just "first mess"
# or "first messagesecond message" all at once!
There are several strategies to solve the framing problem:
Strategy 1: Delimiter — Separate with a Special Character #
# Use \n as a delimiter (the simplest)
# Server — send with a newline at the end
client.puts "first message" # puts automatically adds \n
client.puts "second message"
# Client — read until the newline
msg1 = socket.gets.chomp # read until \n, remove the \n
msg2 = socket.gets.chomp
# JSON-based protocol with a \n delimiter (JSON Lines)
require 'json'
# Server — send a JSON object one per line
def send_json(conn, data)
conn.puts JSON.generate(data)
end
# Client — read and parse JSON one line at a time
def receive_json(conn)
line = conn.gets
return nil if line.nil?
JSON.parse(line.chomp)
end
send_json(client, { type: "response", data: "success", code: 200 })
message = receive_json(socket)
puts message["type"] # => "response"
Strategy 2: Length-Prefix — Prefix with the Message Length #
# Protocol: 4-byte length (big-endian) + message data
# More robust than a delimiter because it can send binary data
def send_message(conn, data)
data_bytes = data.encode("UTF-8")
length = [data_bytes.bytesize].pack("N") # 4 bytes, big-endian
conn.write(length + data_bytes)
end
def receive_message(conn)
# Read exactly 4 bytes for the length
header = conn.read(4)
return nil if header.nil? || header.bytesize < 4
length = header.unpack1("N") # parse 4 bytes into an Integer
# Read exactly 'length' bytes for the data
data = conn.read(length)
return nil if data.nil? || data.bytesize < length
data.force_encoding("UTF-8")
end
# Usage
send_message(client, "Hello from the server!")
message = receive_message(socket)
puts message
Socket Timeouts #
Without timeouts, socket operations can block forever if the network is having issues or the client is unresponsive:
require 'socket'
require 'timeout'
# Method 1: Timeout::timeout — the simplest
begin
Timeout.timeout(5) do
socket = TCPSocket.new("api.example.com", 80)
socket.puts "GET / HTTP/1.0\r\nHost: api.example.com\r\n\r\n"
response = socket.read
puts response
socket.close
end
rescue Timeout::Error
puts "Connection timed out after 5 seconds"
end
# Method 2: setsockopt — timeout at the OS level (more precise)
socket = TCPSocket.new("api.example.com", 80)
# Set a 5-second timeout for receiving
timeout = [5, 0].pack("l_2") # struct timeval: [seconds, microseconds]
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, timeout)
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDTIMEO, timeout)
begin
socket.puts "GET / HTTP/1.0\r\n\r\n"
response = socket.read # raises Errno::EAGAIN on timeout
rescue Errno::EAGAIN, Errno::EWOULDBLOCK
puts "Operation timed out"
ensure
socket.close
end
# Method 3: IO.select — polling with a timeout
ready = IO.select([socket], nil, nil, 5) # wait at most 5 seconds
if ready
data = socket.read_nonblock(4096)
else
puts "No data within 5 seconds (timeout)"
end
IO.select — Multiplexing Without Threads #
IO.select allows a single thread to monitor many sockets at once — useful for simple servers that don’t want the overhead of threads:
require 'socket'
server = TCPServer.new("localhost", 4000)
puts "Server running..."
client_list = []
loop do
# Monitor the server socket AND all connected clients
all_sockets = [server] + client_list
readable, _, error = IO.select(all_sockets, nil, all_sockets, 1.0)
next unless readable
readable.each do |sock|
if sock == server
# An incoming connection
client = server.accept_nonblock
client_list << client
puts "New client: #{client.peeraddr[2]}"
client.puts "Welcome!"
else
# Data from an already-connected client
begin
line = sock.gets_nonblock
if line
puts "Message: #{line.chomp}"
sock.puts "Echo: #{line.chomp}"
end
rescue EOFError, Errno::ECONNRESET
# Client disconnected
puts "Client disconnected"
client_list.delete(sock)
sock.close
rescue IO::WaitReadable
# No data yet (non-blocking)
end
end
end
end
UDP Sockets #
UDP suits applications that prioritize speed and can tolerate lost packets:
require 'socket'
# UDP Server
server = UDPSocket.new
server.bind("localhost", 5000)
puts "UDP server running on port 5000"
loop do
# recvfrom returns [data, [family, port, hostname, ip]]
data, sender = server.recvfrom(1024)
ip = sender[3]
port = sender[1]
puts "Message from #{ip}:#{port}: #{data}"
# Send a reply to the sender
server.send("Message received: '#{data}'", 0, ip, port)
end
require 'socket'
# UDP Client
client = UDPSocket.new
5.times do |i|
message = "Ping #{i + 1}"
client.send(message, 0, "localhost", 5000)
puts "Sent: #{message}"
# Wait for a reply with a timeout
ready = IO.select([client], nil, nil, 2)
if ready
reply, _ = client.recvfrom(1024)
puts "Received: #{reply}"
else
puts "Timeout — no reply"
end
sleep 0.5
end
client.close
Unix Domain Sockets #
A Unix Domain Socket works like TCP but uses a filesystem path as the address — it can only be used between processes on the same machine, but it’s much faster than local TCP:
require 'socket'
SOCKET_PATH = "/tmp/app.sock"
# Server
File.delete(SOCKET_PATH) if File.exist?(SOCKET_PATH) # remove the old socket
server = UNIXServer.new(SOCKET_PATH)
puts "Unix socket server on #{SOCKET_PATH}"
loop do
client = server.accept
Thread.new(client) do |conn|
message = conn.gets&.chomp
conn.puts "Processed: #{message}"
conn.close
end
end
# Client
socket = UNIXSocket.new(SOCKET_PATH)
socket.puts "Hello via Unix socket!"
puts socket.gets.chomp
socket.close
Manual HTTP Client with Sockets #
Understanding how HTTP works on top of TCP is very useful — and it can be implemented with just basic sockets:
require 'socket'
def http_get(host, path = "/", port = 80)
socket = TCPSocket.new(host, port)
# HTTP request
request = [
"GET #{path} HTTP/1.1",
"Host: #{host}",
"Connection: close",
"User-Agent: Ruby-Socket/1.0",
"", # empty line = end of headers
"" # another empty line to ensure \r\n\r\n
].join("\r\n")
socket.write(request)
# Read the response
response = socket.read
socket.close
# Parse the status line and headers
lines = response.split("\r\n")
status_line = lines.shift
puts "Status: #{status_line}"
# Separate headers and body
separator = response.index("\r\n\r\n")
headers = response[0...separator]
body = response[(separator + 4)..]
{ status: status_line, headers: headers, body: body }
end
# Usage
result = http_get("example.com", "/")
puts "Body (first 100 characters):"
puts result[:body][0..100]
SSL/TLS with OpenSSL #
For secure communication, Ruby provides the openssl library that can be wrapped on top of a regular socket:
require 'socket'
require 'openssl'
# HTTPS client with SSL/TLS
host = "www.google.com"
port = 443
# Create a regular TCP socket
tcp_socket = TCPSocket.new(host, port)
# Wrap with an SSL context
ssl_context = OpenSSL::SSL::SSLContext.new
ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER # verify the certificate
ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
ssl_socket.hostname = host # SNI (Server Name Indication)
ssl_socket.connect # perform the TLS handshake
puts "SSL connection established"
puts "Protocol: #{ssl_socket.ssl_version}"
puts "Cipher: #{ssl_socket.cipher[0]}"
# Now you can communicate over the encrypted connection
ssl_socket.write("GET / HTTP/1.1\r\nHost: #{host}\r\nConnection: close\r\n\r\n")
# Read the response
response = ssl_socket.read
puts response[0..200]
ssl_socket.close
tcp_socket.close
# Server with SSL
require 'socket'
require 'openssl'
ssl_context = OpenSSL::SSL::SSLContext.new
ssl_context.cert = OpenSSL::X509::Certificate.new(File.read("server.crt"))
ssl_context.key = OpenSSL::PKey::RSA.new(File.read("server.key"))
tcp_server = TCPServer.new("0.0.0.0", 4433)
ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, ssl_context)
puts "SSL server running on port 4433"
loop do
ssl_client = ssl_server.accept
Thread.new(ssl_client) do |conn|
puts "SSL client from #{conn.io.peeraddr[2]}"
conn.puts "Hello over SSL!"
conn.close
end
end
Robust Server Patterns #
Combining all the concepts above into a TCP server that’s truly ready for production:
require 'socket'
require 'logger'
class RobustServer
MAX_CLIENTS = 100
MESSAGE_LIMIT = 64 * 1024 # 64 KB per message
def initialize(host, port)
@host = host
@port = port
@log = Logger.new($stdout)
@log.formatter = proc { |level, _, _, msg| "[#{level}] #{msg}\n" }
@running = false
@mutex = Mutex.new
@clients = {} # socket => thread
end
def start
@server = TCPServer.new(@host, @port)
@server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
@running = true
@log.info "Server running on #{@host}:#{@port}"
# Handle SIGTERM and SIGINT for graceful shutdown
Signal.trap("TERM") { stop }
Signal.trap("INT") { stop }
loop do
break unless @running
begin
client = @server.accept_nonblock
if client_count >= MAX_CLIENTS
client.puts "ERROR: Server full, try again later"
client.close
next
end
register_client(client)
rescue IO::WaitReadable
# No incoming clients, try again
IO.select([@server], nil, nil, 0.1)
rescue Errno::EBADF
break # the server socket was already closed
end
end
end
def stop
@log.info "Shutting down the server..."
@running = false
@mutex.synchronize do
@clients.each_key do |sock|
sock.puts "The server is shutting down, the connection will be closed."
sock.close rescue nil
end
@clients.clear
end
@server.close rescue nil
@log.info "Server finished."
end
private
def client_count
@mutex.synchronize { @clients.size }
end
def register_client(sock)
thread = Thread.new { handle_client(sock) }
@mutex.synchronize { @clients[sock] = thread }
end
def handle_client(sock)
addr = sock.peeraddr[2]
@log.info "Client connected: #{addr}"
loop do
line = sock.gets
break if line.nil?
line.chomp!
@log.info "[#{addr}] #{line}"
sock.puts "OK: #{line}"
end
rescue Errno::ECONNRESET, Errno::EPIPE, IOError
@log.info "Connection lost: #{sock.peeraddr[2] rescue 'unknown'}"
ensure
@mutex.synchronize { @clients.delete(sock) }
sock.close rescue nil
@log.info "Client released: total active #{client_count}"
end
end
server = RobustServer.new("0.0.0.0", 4000)
server.start
Summary #
- TCP for reliability, UDP for speed — choose based on your needs: TCP for data that must not be lost, UDP for streaming that prioritizes low latency.
- Framing is the most common problem in socket programming — TCP is a stream, not packets; use a delimiter (
\n) or a length-prefix (4-byte length) to distinguish message boundaries.SO_REUSEADDRis almost always required — without it, a server can’t be restarted immediately after stopping because the port is still inTIME_WAIT.- Each client needs a thread or non-blocking I/O — a server that handles one client at a time (blocking) is only suitable for demos; use
Thread.newper client orIO.selectfor multiplexing.- Always handle connection exceptions —
Errno::ECONNRESET,Errno::EPIPE, andEOFErrorcan happen at any time; the server must recover without crashing.- Timeouts are mandatory for sockets — connections without timeouts can hang forever if the network misbehaves; use
setsockopt SO_RCVTIMEOorTimeout.timeout.- Unix Domain Sockets are faster than local TCP — for inter-process communication on the same machine, use
UNIXServer/UNIXSocket.- Wrap with SSL/TLS for security —
OpenSSL::SSL::SSLSocketcan be wrapped on top of a regular TCP socket with a few lines of code.- Graceful shutdown is essential — handle
SIGTERMandSIGINT, close all client connections with a notification before closing the server.IO.selectfor lightweight multiplexing — an alternative to threading for servers with many idle connections that don’t need thread overhead.