When I first started writing scripts that needed to reach out beyond my own machine — pulling data from an API, uploading a file to a remote server, or just checking if a host was alive — I quickly realized that Ruby’s networking story is a lot bigger than most tutorials let on. It’s not just Net::HTTP.get. There’s a whole stack underneath: sockets, protocols, buffering, timeouts, and a standard library that gives you as much rope as you want to hang yourself with (or build something genuinely solid). In this guide I want to walk through everything I’ve learned about talking to networks in Ruby, from the lowest-level socket to the highest-level HTTP client, in a way that actually makes sense when you sit down to build something real.
Why Networking Matters in Ruby
Ruby isn’t usually the first language people associate with networking — that reputation tends to go to Go or C — but Ruby has had solid networking support since its earliest days, largely inherited from its Unix roots. Rails itself is built on top of Ruby’s networking stack, and so is nearly every gem that talks to an external service: httparty, faraday, net-ftp, aws-sdk, you name it. Understanding the layer beneath these gems makes you a far more effective Ruby developer, because when something breaks — a connection resets, a timeout fires, an SSL handshake fails — you need to know what’s actually happening under the hood.
The Building Blocks: Ruby’s Networking Libraries
Ruby ships with several networking-related libraries in its standard library:
Socket— the lowest-level interface, a thin wrapper around the OS socket API (BSD sockets on Unix-like systems).Net::HTTP— the built-in HTTP client, used for most web requests.Net::FTP— for File Transfer Protocol operations.OpenURI— a convenience layer on top ofNet::HTTP(and others) for quick, one-line resource fetching.URI— for parsing and constructing URLs, which almost always accompanies the above.
Let’s go through each of these in a logical order: sockets first (because everything else is built on them), then HTTP, then FTP.
Sockets: The Foundation
Every network conversation in Ruby, no matter how high-level the API looks, eventually becomes a socket. A socket is just an endpoint for sending and receiving data across a network — think of it as a phone line between two programs.
Here’s a minimal TCP server and client using Ruby’s Socket library:
# server.rb
require 'socket'
server = TCPServer.new('127.0.0.1', 4481)
puts "Server listening on port 4481..."
loop do
client = server.accept
request = client.gets
puts "Received: #{request.chomp}"
client.puts "Hello, #{request.chomp}! The server says hi."
client.close
end
# client.rb
require 'socket'
socket = TCPSocket.new('127.0.0.1', 4481)
socket.puts "Ruby Developer"
response = socket.gets
puts response
socket.close
Running the server and then the client produces:
$ ruby server.rb
Server listening on port 4481...
Received: Ruby Developer
$ ruby client.rb
Hello, Ruby Developer! The server says hi.
What’s happening internally here is worth understanding. TCPServer.new calls the operating system’s socket(), bind(), and listen() system calls. accept blocks the current thread until a client connects, then hands back a new socket object representing that specific connection. gets and puts on a socket behave almost exactly like they do on STDIN/STDOUT, because Socket objects are a subclass of Ruby’s IO class — this is one of Ruby’s nicer design decisions, since it means anything that works with files or standard input generally works with sockets too.
One thing that trips people up: TCPServer#accept is a blocking call. In a single-threaded script, your server can only handle one client at a time unless you explicitly fork a process or spawn a thread per connection:
loop do
client = server.accept
Thread.new(client) do |conn|
request = conn.gets
conn.puts "Echo: #{request}"
conn.close
end
end
Because of Ruby’s Global VM Lock (GVL) in MRI, threads don’t give you true CPU parallelism, but for I/O-bound work like network communication, this is fine — threads release the GVL while waiting on I/O, so a thread-per-connection model scales reasonably well for many concurrent, mostly-idle connections.
HTTP: The Workhorse Protocol
Most real-world Ruby networking code talks HTTP, not raw sockets. Ruby’s built-in Net::HTTP is verbose but complete, and understanding it well means you’ll never be stuck when a gem like Faraday doesn’t quite do what you need.
A Simple GET Request
require 'net/http'
require 'uri'
require 'json'
uri = URI('https://api.github.com/users/octocat')
response = Net::HTTP.get_response(uri)
puts response.code # => "200"
puts response.message # => "OK"
data = JSON.parse(response.body)
puts data['name']
puts data['public_repos']
Output:
200
OK
The Octocat
8
POST Requests with a Body
require 'net/http'
require 'uri'
require 'json'
uri = URI('https://httpbin.org/post')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
request.body = { name: 'Ruby', type: 'language' }.to_json
response = http.request(request)
puts response.code
puts JSON.parse(response.body)['json']
This gives you:
200
{"name"=>"Ruby", "type"=>"language"}
Notice a few important pieces here. http.use_ssl = true is required for any https:// URL — Ruby doesn’t infer it automatically from the URI scheme when you build the connection this way, which is a classic gotcha. Forgetting this line either raises a connection error or, worse, silently fails depending on your Ruby version.
Timeouts and Error Handling
In production code, you should always set explicit timeouts. Without them, a hung connection can block your process indefinitely:
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = 5 # seconds to establish connection
http.read_timeout = 10 # seconds to wait for response
begin
response = http.request(request)
rescue Net::OpenTimeout, Net::ReadTimeout => e
puts "Request timed out: #{e.message}"
rescue SocketError => e
puts "Could not resolve host: #{e.message}"
rescue => e
puts "Something went wrong: #{e.class} - #{e.message}"
end
OpenURI: The Quick-and-Dirty Option
For fast, one-off fetches, OpenURI is genuinely convenient:
require 'open-uri'
content = URI.open('https://www.ruby-lang.org').read
puts content.length
I use this for scripts and quick data pulls, but I’ve learned to avoid it in production HTTP clients — it hides a lot of the configurability (custom headers, retries, connection pooling) that Net::HTTP or a gem like Faraday gives you directly, and it has had a history of security advisories related to how it handles redirects and local file access, so I always pin recent Ruby/OpenURI versions when I use it.
FTP: Moving Files Across the Network
FTP feels old-fashioned in a world of S3 buckets and REST APIs, but it’s still alive in plenty of legacy systems — payment processors, government data feeds, and internal enterprise tools still use it constantly. Ruby’s Net::FTP (distributed as the net-ftp gem since Ruby 3.1, since many standard libraries were unbundled) handles this cleanly.
require 'net/ftp'
Net::FTP.open('ftp.example.com', username: 'myuser', password: 'mypass') do |ftp|
ftp.passive = true
puts ftp.pwd
puts ftp.list.first(5)
ftp.chdir('/reports')
ftp.get('quarterly_report.csv', 'local_report.csv')
ftp.putbinaryfile('local_upload.zip', 'remote_upload.zip')
end
A few notes from experience:
- Always use passive mode (
ftp.passive = true) unless you have a specific reason not to. Active mode requires the server to connect back to your client, which almost never works behind NAT or a firewall. getandputtransfer files in text mode by default, which can corrupt binary files like images or zip archives — usegetbinaryfileandputbinaryfilefor anything that isn’t plain text.- FTP credentials go over the wire unencrypted unless you’re using FTPS (
Net::FTP.new(host, ssl: true)) or switching to SFTP entirely (which is a different protocol built on SSH, handled by the separatenet-sftpgem).
Internal Working: What Happens Under the Hood
It’s worth understanding what’s actually going on when you call Net::HTTP.get. Ruby opens a TCP socket to the target host and port (443 for HTTPS, 80 for HTTP), performs a TCP three-way handshake, and — for HTTPS — layers a TLS handshake on top using OpenSSL bindings compiled into Ruby. Once the encrypted channel exists, Ruby writes the raw HTTP request line, headers, and body as plain text (even though it’s flowing through an encrypted socket), then reads the response back the same way: status line, headers, then body, following the HTTP/1.1 spec’s rules about Content-Length and chunked transfer encoding.
Every Net::HTTP connection object wraps a TCPSocket (or OpenSSL::SSL::SSLSocket for HTTPS) internally. Ruby’s IO buffering means reads and writes aren’t hitting the kernel on every single call — there’s a buffer in userspace that Ruby fills and drains, which is part of why explicit flush calls matter in some low-level socket code but rarely matter with Net::HTTP, since it handles buffering correctly for you.
Memory-wise, response bodies are read fully into a Ruby String by default unless you stream them. For large files, use block-form reading to avoid loading gigabytes into memory:
http.request(request) do |response|
response.read_body do |chunk|
file.write(chunk)
end
end
Real-World Applications
I’ve used the patterns above for:
- API integrations — pulling data from third-party REST APIs, handling pagination, retries, and rate limits.
- Webhook receivers — small Sinatra or plain
TCPServer-based services listening for incoming POST requests from services like Stripe or GitHub. - Health check scripts — simple TCP socket checks (
TCPSocket.new(host, port)inside aTimeout.timeoutblock) to verify a service is reachable before deploying. - Legacy data sync jobs — nightly cron scripts that pull CSV exports off a partner’s FTP server and load them into a database.
Best Practices
A few things I’ve learned to do consistently:
- Always set timeouts. Every HTTP or socket connection should have both an open timeout and a read timeout.
- Rescue specific exceptions, not a blanket
rescue => ethat swallows everything silently. - Reuse connections where possible using
Net::HTTP.startwith a block, rather than opening a fresh TCP connection for every request. - Verify SSL certificates. Never set
http.verify_mode = OpenSSL::SSL::VERIFY_NONEoutside of local debugging — it disables protection against man-in-the-middle attacks. - Close what you open. Sockets and file handles left open leak resources; prefer block forms (
Net::HTTP.start(...) { ... },Net::FTP.open(...) { ... }) which close automatically. - Reach for a gem like Faraday or HTTParty once your HTTP needs grow beyond simple requests — they add retry logic, middleware, and cleaner syntax on top of
Net::HTTP.
Common Mistakes
- Forgetting
use_ssl = truefor HTTPS URLs. - Not handling redirects —
Net::HTTPdoes not follow redirects automatically; you have to check forNet::HTTPRedirectionand re-request theLocationheader yourself. - Reading entire large responses into memory instead of streaming.
- Using active-mode FTP behind a firewall and wondering why the connection hangs.
- Assuming
getson a socket won’t block forever — always wrap blocking socket calls inTimeout.timeoutor set socket-level timeouts.
Debugging Tips
When something isn’t working, Net::HTTP has a built-in debug flag that’s saved me hours:
http.set_debug_output($stdout)
This prints the raw request and response, including headers, straight to your terminal — invaluable for spotting a missing header or an unexpected redirect. For raw socket issues, tools like tcpdump or Wireshark outside of Ruby are your best friends when you need to see exactly what’s crossing the wire.
Summary
Ruby gives you a full spectrum of networking tools: raw Socket objects when you need full control, Net::HTTP for the vast majority of web communication, and Net::FTP for the file-transfer use cases that refuse to die. Understanding how these layer on top of each other — sockets underneath, protocols on top, your application logic on top of that — makes debugging network issues far less mysterious. Start with the standard library, understand what it’s really doing, and only reach for a heavier HTTP gem once you know exactly what problem it’s solving for you.
References
- Ruby’s official
Net::HTTPdocumentation: https://docs.ruby-lang.org/en/master/Net/HTTP.html - Ruby’s official
Socketdocumentation: https://docs.ruby-lang.org/en/master/Socket.html net-ftpgem on RubyGems: https://rubygems.org/gems/net-ftpOpenURIdocumentation: https://docs.ruby-lang.org/en/master/OpenURI.html- Faraday gem: https://rubygems.org/gems/faraday