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_REUSEADDR is almost always required — without it, a server can’t be restarted immediately after stopping because the port is still in TIME_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.new per client or IO.select for multiplexing.
  • Always handle connection exceptionsErrno::ECONNRESET, Errno::EPIPE, and EOFError can 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_RCVTIMEO or Timeout.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 securityOpenSSL::SSL::SSLSocket can be wrapped on top of a regular TCP socket with a few lines of code.
  • Graceful shutdown is essential — handle SIGTERM and SIGINT, close all client connections with a notification before closing the server.
  • IO.select for lightweight multiplexing — an alternative to threading for servers with many idle connections that don’t need thread overhead.

← Previous: I/O   Next: WebSocket →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact