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

Socket and network libraries in Ruby

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:

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:

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:

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

Common Mistakes and Debugging Tips

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

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

Exit mobile version