Overview
Keep-alive connections maintain TCP connections between client and server for multiple HTTP requests, eliminating the overhead of repeated connection establishment and teardown. In HTTP/1.0, each request required a new TCP connection, incurring the cost of a three-way handshake, slow start congestion control, and connection termination for every resource. HTTP/1.1 introduced persistent connections as the default behavior, changing web performance characteristics substantially.
The mechanism addresses a fundamental inefficiency in early HTTP architecture. When browsers request a web page with dozens of resources (HTML, CSS, JavaScript, images), creating separate connections for each resource multiplies network overhead. Each connection requires DNS resolution, TCP handshake (one round-trip), TLS handshake if using HTTPS (two additional round-trips), and congestion window initialization. For a page with 50 resources, this overhead becomes significant.
Modern web applications rely on connection persistence. API clients making multiple requests to the same server, web scrapers fetching numerous pages, and microservices communicating internally all benefit from connection reuse. The difference between fresh connections and persistent connections can mean the difference between 200ms and 20ms response times when network latency is involved.
# Without keep-alive: new connection per request
require 'net/http'
uri = URI('https://api.example.com/users/1')
response1 = Net::HTTP.get_response(uri) # Connection 1
uri = URI('https://api.example.com/users/2')
response2 = Net::HTTP.get_response(uri) # Connection 2 (new handshake)
# With keep-alive: connection reused
require 'net/http'
Net::HTTP.start('api.example.com', 443, use_ssl: true) do |http|
response1 = http.get('/users/1') # Connection established
response2 = http.get('/users/2') # Same connection reused
response3 = http.get('/users/3') # Still same connection
end
Connection persistence operates at the transport layer but requires coordination with the application layer. The server must explicitly support keep-alive and communicate connection parameters through HTTP headers. Both client and server must manage connection state, handle timeouts, and detect stale connections.
Key Principles
Connection persistence operates through several coordinated mechanisms that maintain TCP state across multiple HTTP transactions. The fundamental principle involves the server declaring its willingness to keep connections alive and the client choosing to reuse those connections for subsequent requests.
Connection Lifecycle Management
A persistent connection transitions through distinct states: establishment, active usage, idle periods, and eventual closure. During establishment, standard TCP handshake occurs. The server includes Connection: keep-alive header (HTTP/1.0) or omits Connection: close header (HTTP/1.1) to signal persistence support. The connection enters active state when transmitting request/response data, then transitions to idle state awaiting the next request.
Idle connections present the primary challenge. Both client and server must decide when to close idle connections. Servers typically implement timeout policies: connections inactive for a specified duration close automatically. Clients must detect when servers have closed connections and handle reconnection transparently.
Request-Response Boundaries
HTTP defines message boundaries through Content-Length headers or chunked transfer encoding. Without clear boundaries, the client cannot determine where one response ends and the next begins on a persistent connection. The Content-Length header specifies exact byte count. Chunked encoding uses special framing:
HTTP/1.1 200 OK
Transfer-Encoding: chunked
5
Hello
6
World
0
The final chunk (size 0) signals response completion. Incorrect boundary handling results in data corruption or connection stalls.
Connection Headers
The Connection header controls persistence behavior. In HTTP/1.0, clients must explicitly request persistence: Connection: keep-alive. The server responds with Connection: keep-alive and Keep-Alive header parameters. HTTP/1.1 reversed the default: connections persist unless explicitly closed with Connection: close.
The Keep-Alive header provides connection parameters:
Connection: keep-alive
Keep-Alive: timeout=5, max=100
This signals the server will maintain the connection for 5 seconds of idle time and accept up to 100 requests on this connection. These values inform client connection pool management.
Connection Pooling
Clients typically maintain connection pools rather than single connections. For a given host and port, the pool holds multiple connections (often 2-6 per host). When making requests, the client borrows an available connection, uses it, and returns it to the pool. This pattern handles concurrent requests to the same host while respecting connection limits.
Connection pools must handle connection health. Stale connections that servers closed remain in the pool until detected. Clients typically verify connection health before reusing pooled connections or handle connection errors by retrying with fresh connections.
Timeout Mechanisms
Three distinct timeouts govern persistent connections:
- Connection timeout: Maximum time to establish initial TCP connection
- Read timeout: Maximum time waiting for response data after request sent
- Idle timeout: Maximum time connection remains open without activity
Idle timeout management requires coordination. Clients should close connections before server timeout expires to prevent servers from closing connections the client believes active. In practice, clients set idle timeouts slightly shorter than server timeouts.
Graceful Closure
Either party can close persistent connections. The server sends Connection: close header in a response to signal this is the final transaction on this connection. The client must not send additional requests on this connection. Clients similarly send Connection: close when they choose to terminate persistence.
Ungraceful closure occurs when TCP connections terminate without HTTP-level coordination. Network failures, server crashes, or process termination result in TCP RST or FIN without proper HTTP signaling. Clients must detect these scenarios and handle reconnection.
Ruby Implementation
Ruby's standard library and third-party gems provide varying levels of keep-alive support. Understanding each library's connection management model determines appropriate usage patterns.
Net::HTTP Persistent Connections
The standard library Net::HTTP supports persistent connections through its block-based API. Calling Net::HTTP.start establishes a connection, yields it to the block, and maintains the connection for multiple requests within that block:
require 'net/http'
uri = URI('https://api.github.com')
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
# Connection established once
# First request
request = Net::HTTP::Get.new('/users/octocat')
request['User-Agent'] = 'Ruby Script'
response = http.request(request)
# Second request reuses connection
request = Net::HTTP::Get.new('/users/octocat/repos')
response = http.request(request)
# Third request still reuses connection
request = Net::HTTP::Get.new('/users/octocat/followers')
response = http.request(request)
end
# Connection closed when block exits
Net::HTTP also supports manual connection management without blocks:
require 'net/http'
http = Net::HTTP.new('api.example.com', 443)
http.use_ssl = true
http.start
# Connection established and persisted
response1 = http.get('/resource1')
response2 = http.get('/resource2')
http.finish # Explicitly close connection
The manual approach requires explicit start and finish calls. Failure to call finish leaves connections open until garbage collection or process termination.
Net::HTTP does not provide built-in connection pooling. Each Net::HTTP instance represents a single connection. Applications requiring concurrent requests to the same host must manage multiple Net::HTTP instances:
require 'net/http'
require 'thread'
uri = URI('https://api.example.com')
connections = Array.new(5) do
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.start
http
end
threads = (1..10).map do |i|
Thread.new do
connection = connections[i % 5] # Round-robin distribution
response = connection.get("/items/#{i}")
puts "Thread #{i}: #{response.code}"
end
end
threads.each(&:join)
connections.each(&:finish)
Net::HTTP::Persistent
The net-http-persistent gem extends Net::HTTP with automatic connection pooling and reconnection handling:
require 'net/http/persistent'
http = Net::HTTP::Persistent.new(name: 'my_app')
# First request establishes connection
uri = URI('https://api.example.com/users/1')
response = http.request(uri)
# Subsequent requests reuse connection automatically
uri = URI('https://api.example.com/users/2')
response = http.request(uri)
# Different host uses different connection from pool
uri = URI('https://api.other.com/data')
response = http.request(uri)
# Shutdown all connections
http.shutdown
The gem manages per-host connection pools automatically. It handles stale connection detection and reconnection transparently. Configuration options control pool behavior:
require 'net/http/persistent'
http = Net::HTTP::Persistent.new(
name: 'my_app',
pool_size: 10, # Connections per host
idle_timeout: 5, # Seconds before closing idle connections
read_timeout: 10, # Response read timeout
open_timeout: 5 # Connection establishment timeout
)
# Override timeout for specific request
uri = URI('https://slow-api.example.com/data')
http.override_headers['User-Agent'] = 'CustomAgent/1.0'
response = http.request(uri)
Faraday with Connection Pooling
Faraday provides a high-level HTTP client interface with adapter-based architecture. The default adapter uses Net::HTTP without persistent connections. The net_http_persistent adapter enables connection pooling:
require 'faraday'
require 'net/http/persistent'
conn = Faraday.new(url: 'https://api.example.com') do |f|
f.adapter :net_http_persistent, pool_size: 5
end
# Connections managed automatically
response1 = conn.get('/users/1')
response2 = conn.get('/users/2')
response3 = conn.get('/users/3')
Faraday's middleware stack allows customization while maintaining persistent connections:
require 'faraday'
conn = Faraday.new(url: 'https://api.example.com') do |f|
f.request :json
f.response :json
f.response :raise_error
f.adapter :net_http_persistent, pool_size: 10 do |http|
http.idle_timeout = 10
http.read_timeout = 30
end
end
# Middleware processes requests/responses
# Connection pooling handles HTTP transport
response = conn.post('/users') do |req|
req.body = { name: 'Alice', email: 'alice@example.com' }
end
HTTP.rb Library
The http gem provides a chainable HTTP client with built-in persistent connection support:
require 'http'
# Persistent client
client = HTTP.persistent('https://api.example.com')
# Requests reuse connection
response1 = client.get('/users/1')
response2 = client.get('/users/2')
response3 = client.get('/users/3')
# Close persistent connection
client.close
The library handles connection state automatically and provides fluent API for request customization:
require 'http'
client = HTTP.persistent('https://api.example.com')
.headers(user_agent: 'MyApp/1.0')
.timeout(connect: 5, read: 10)
response = client.get('/data')
puts response.body.to_s
client.close
Connection Pool Implementation
Building a custom connection pool demonstrates the underlying concepts:
require 'net/http'
require 'monitor'
class ConnectionPool
def initialize(host, port, pool_size: 5, idle_timeout: 5)
@host = host
@port = port
@pool_size = pool_size
@idle_timeout = idle_timeout
@available = []
@in_use = {}
@monitor = Monitor.new
end
def with_connection
connection = acquire
begin
yield connection
ensure
release(connection)
end
end
private
def acquire
@monitor.synchronize do
# Remove stale connections
@available.reject! { |conn| stale?(conn) }
# Return available connection
if @available.any?
conn = @available.pop
@in_use[conn] = Time.now
return conn
end
# Create new connection if under limit
if @in_use.size < @pool_size
conn = create_connection
@in_use[conn] = Time.now
return conn
end
# Wait for available connection
sleep 0.1
acquire
end
end
def release(connection)
@monitor.synchronize do
@in_use.delete(connection)
@available.push(connection) if connection.started?
end
end
def create_connection
http = Net::HTTP.new(@host, @port)
http.use_ssl = true
http.start
http
end
def stale?(connection)
!connection.started? ||
(Time.now - @in_use.fetch(connection, Time.now)) > @idle_timeout
end
end
# Usage
pool = ConnectionPool.new('api.example.com', 443, pool_size: 5)
5.times do |i|
pool.with_connection do |http|
response = http.get("/items/#{i}")
puts "Request #{i}: #{response.code}"
end
end
Practical Examples
API Client with Connection Reuse
An API client making multiple requests demonstrates connection reuse benefits:
require 'net/http/persistent'
require 'json'
class GitHubClient
def initialize(token)
@token = token
@http = Net::HTTP::Persistent.new(name: 'github_client')
end
def user_repositories(username)
uri = URI("https://api.github.com/users/#{username}/repos")
request = Net::HTTP::Get.new(uri)
request['Authorization'] = "token #{@token}"
request['Accept'] = 'application/vnd.github.v3+json'
response = @http.request(uri, request)
JSON.parse(response.body)
end
def repository_details(owner, repo)
uri = URI("https://api.github.com/repos/#{owner}/#{repo}")
request = Net::HTTP::Get.new(uri)
request['Authorization'] = "token #{@token}"
request['Accept'] = 'application/vnd.github.v3+json'
response = @http.request(uri, request)
JSON.parse(response.body)
end
def repository_commits(owner, repo)
uri = URI("https://api.github.com/repos/#{owner}/#{repo}/commits")
request = Net::HTTP::Get.new(uri)
request['Authorization'] = "token #{@token}"
request['Accept'] = 'application/vnd.github.v3+json'
response = @http.request(uri, request)
JSON.parse(response.body)
end
def close
@http.shutdown
end
end
# All requests reuse connection to api.github.com
client = GitHubClient.new(ENV['GITHUB_TOKEN'])
repos = client.user_repositories('octocat')
details = client.repository_details('octocat', repos.first['name'])
commits = client.repository_commits('octocat', repos.first['name'])
client.close
Web Scraper with Connection Pool
A web scraper fetching multiple pages benefits from connection pooling:
require 'net/http/persistent'
require 'nokogiri'
require 'uri'
class WebScraper
def initialize
@http = Net::HTTP::Persistent.new(
name: 'scraper',
pool_size: 10,
idle_timeout: 10
)
end
def scrape_articles(base_url, page_count)
results = []
page_count.times do |page|
url = URI("#{base_url}?page=#{page + 1}")
response = @http.request(url)
if response.code == '200'
doc = Nokogiri::HTML(response.body)
doc.css('article').each do |article|
title = article.css('h2').text.strip
link = article.css('a').first['href']
# Fetch full article (reuses connection)
full_url = URI.join(base_url, link)
article_response = @http.request(full_url)
article_doc = Nokogiri::HTML(article_response.body)
content = article_doc.css('.content').text.strip
results << {
title: title,
url: full_url.to_s,
content: content
}
end
end
sleep 0.5 # Rate limiting
end
results
end
def close
@http.shutdown
end
end
scraper = WebScraper.new
articles = scraper.scrape_articles('https://example.com/blog', 5)
scraper.close
puts "Scraped #{articles.size} articles"
Microservice Communication
Microservices making inter-service calls benefit from persistent connections:
require 'faraday'
require 'json'
class UserService
def initialize(auth_service_url, profile_service_url)
@auth_conn = Faraday.new(url: auth_service_url) do |f|
f.adapter :net_http_persistent, pool_size: 5
end
@profile_conn = Faraday.new(url: profile_service_url) do |f|
f.adapter :net_http_persistent, pool_size: 5
end
end
def authenticate(username, password)
response = @auth_conn.post('/auth/login') do |req|
req.headers['Content-Type'] = 'application/json'
req.body = JSON.generate(username: username, password: password)
end
JSON.parse(response.body)
end
def get_profile(user_id, token)
response = @profile_conn.get("/users/#{user_id}") do |req|
req.headers['Authorization'] = "Bearer #{token}"
end
JSON.parse(response.body)
end
def update_profile(user_id, token, data)
response = @profile_conn.put("/users/#{user_id}") do |req|
req.headers['Authorization'] = "Bearer #{token}"
req.headers['Content-Type'] = 'application/json'
req.body = JSON.generate(data)
end
JSON.parse(response.body)
end
end
service = UserService.new(
'http://auth-service:3000',
'http://profile-service:3001'
)
# Multiple calls reuse connections
auth_result = service.authenticate('alice', 'password')
token = auth_result['token']
profile = service.get_profile(123, token)
updated = service.update_profile(123, token, { bio: 'Updated bio' })
Batch Processing with Connection Reuse
Processing large datasets with API calls benefits from connection persistence:
require 'net/http/persistent'
require 'json'
class BatchProcessor
def initialize(api_url, batch_size: 100)
@api_url = api_url
@batch_size = batch_size
@http = Net::HTTP::Persistent.new(
name: 'batch_processor',
idle_timeout: 30,
read_timeout: 60
)
end
def process_records(records)
results = []
failures = []
records.each_slice(@batch_size) do |batch|
batch.each do |record|
begin
uri = URI("#{@api_url}/process")
request = Net::HTTP::Post.new(uri)
request['Content-Type'] = 'application/json'
request.body = JSON.generate(record)
response = @http.request(uri, request)
if response.code == '200'
results << JSON.parse(response.body)
else
failures << { record: record, error: response.body }
end
rescue => e
failures << { record: record, error: e.message }
end
end
# Brief pause between batches
sleep 0.1
end
{ successes: results, failures: failures }
end
def close
@http.shutdown
end
end
# Process 10,000 records using same connection
records = (1..10_000).map { |i| { id: i, data: "Record #{i}" } }
processor = BatchProcessor.new('https://api.example.com', batch_size: 100)
result = processor.process_records(records)
processor.close
puts "Processed: #{result[:successes].size}"
puts "Failed: #{result[:failures].size}"
Performance Considerations
Connection persistence provides measurable performance improvements by eliminating repeated connection establishment overhead. The magnitude of improvement depends on network latency, TLS usage, and request frequency.
Latency Reduction
Each new TCP connection requires a three-way handshake: client sends SYN, server responds with SYN-ACK, client sends ACK. This process consumes one round-trip time (RTT). For HTTPS connections, TLS handshake adds two more round-trips (TLS 1.2) or one round-trip (TLS 1.3). Without keep-alive:
Non-persistent connection (HTTP):
- DNS lookup: 20ms
- TCP handshake: 50ms (RTT)
- Request/Response: 50ms (RTT)
Total: 120ms per request
Non-persistent connection (HTTPS with TLS 1.2):
- DNS lookup: 20ms
- TCP handshake: 50ms
- TLS handshake: 100ms (2 RTT)
- Request/Response: 50ms
Total: 220ms per request
With persistent connections, subsequent requests skip connection setup:
Persistent connection (HTTPS):
- First request: 220ms (as above)
- Second request: 50ms (RTT only)
- Third request: 50ms (RTT only)
Average: 106ms per request (61% improvement)
The benefit compounds with more requests. Ten requests over persistent connection average 67ms each versus 220ms for separate connections.
Connection Limits
Browsers and HTTP clients enforce per-host connection limits. Browsers typically allow 6 simultaneous connections per host. Without keep-alive, these connections close after each response, allowing new connections. With keep-alive, connections remain open, potentially blocking new requests until existing requests complete.
Applications must balance connection pool size against resource usage. Too few connections create bottlenecks under load. Too many connections waste server resources and socket file descriptors. A common approach:
Connection Pool Size = (Expected Peak Concurrent Requests / Average Request Duration) * 1.5
Example:
- Peak: 100 requests/second to single host
- Average duration: 100ms = 0.1s
- Concurrent requests: 100 * 0.1 = 10
- Pool size: 10 * 1.5 = 15 connections
Memory and Resource Usage
Each persistent connection consumes resources on both client and server:
- TCP socket file descriptors (limited by OS)
- Kernel memory for socket buffers (typically 128KB send + 128KB receive)
- Application memory for connection state
- Server worker threads or event loop resources
Servers with 10,000 persistent connections consume:
Socket buffers: 10,000 * 256KB = 2.5GB kernel memory
File descriptors: 10,000 (requires ulimit adjustment)
Application memory: varies by implementation
Servers must configure maximum concurrent connections and implement connection limiting. When limits reached, servers can:
- Reject new connections (TCP RST)
- Send
Connection: closeon existing connections to free slots - Queue incoming connections (TCP backlog)
Idle Connection Overhead
Idle persistent connections consume resources without performing work. Aggressive idle timeout policies (1-5 seconds) reduce resource waste but increase connection churn. Conservative timeouts (60-300 seconds) reduce handshake overhead but waste resources.
The optimal timeout depends on request patterns:
High-frequency APIs (requests every few seconds):
- Long timeout (60-120s)
- Minimize reconnection overhead
Low-frequency APIs (requests every few minutes):
- Short timeout (5-15s)
- Minimize idle resource consumption
Bursty traffic (occasional high activity):
- Medium timeout (30-60s)
- Balance responsiveness and resources
Concurrent Request Handling
HTTP/1.1 persistent connections process requests serially: the second request waits for the first response. Connection pooling enables parallelism by maintaining multiple connections per host:
require 'net/http/persistent'
require 'thread'
http = Net::HTTP::Persistent.new(name: 'parallel', pool_size: 10)
# 10 concurrent requests use 10 different connections
threads = (1..10).map do |i|
Thread.new do
uri = URI("https://api.example.com/data/#{i}")
response = http.request(uri)
puts "Thread #{i}: #{response.code}"
end
end
threads.each(&:join)
http.shutdown
HTTP/2 multiplexing allows multiple concurrent requests over a single connection, but HTTP/2 adoption in Ruby clients varies by library.
Benchmark Comparison
Comparing persistent and non-persistent connections:
require 'net/http'
require 'net/http/persistent'
require 'benchmark'
url = URI('https://httpbin.org/get')
iterations = 50
# Non-persistent (new connection per request)
non_persistent_time = Benchmark.realtime do
iterations.times do
Net::HTTP.get_response(url)
end
end
# Persistent connection
persistent_time = Benchmark.realtime do
http = Net::HTTP::Persistent.new(name: 'bench')
iterations.times do
http.request(url)
end
http.shutdown
end
puts "Non-persistent: #{non_persistent_time.round(2)}s"
puts "Persistent: #{persistent_time.round(2)}s"
puts "Improvement: #{((1 - persistent_time / non_persistent_time) * 100).round(1)}%"
# Typical output:
# Non-persistent: 12.34s
# Persistent: 4.56s
# Improvement: 63.0%
Security Implications
Persistent connections introduce security considerations distinct from non-persistent connections. Connection lifetime, resource management, and state handling create attack surfaces requiring mitigation.
Connection Hijacking
Long-lived connections provide extended windows for session hijacking. If an attacker intercepts a persistent connection through network compromise (ARP spoofing, DNS hijacking), they gain access to all subsequent requests on that connection. TLS mitigates this through encryption, but implementation flaws or certificate validation bypasses enable attacks.
Clients must validate TLS certificates on connection establishment and should not bypass certificate validation for convenience:
# Insecure: disables certificate verification
http = Net::HTTP.new('api.example.com', 443)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE # NEVER do this
# Secure: proper certificate verification
http = Net::HTTP.new('api.example.com', 443)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
Denial of Service Through Connection Exhaustion
Attackers can exhaust server connection limits by opening many persistent connections and sending minimal traffic to keep them alive. This prevents legitimate clients from connecting. The Slowloris attack exploits this by opening hundreds of connections and sending partial HTTP requests slowly.
Servers mitigate this through:
- Strict connection limits per IP address
- Aggressive timeout policies
- Rate limiting on connection establishment
- Detection of slow request patterns
Clients can unintentionally cause similar issues through connection pool leaks. Connections acquired but never released accumulate until the pool exhausts:
# Dangerous: connection not released on error
def fetch_data(http, path)
http.request(URI("https://api.example.com#{path}"))
# If an exception occurs, connection remains in use
end
# Safe: ensure release with ensure block
def fetch_data(http, path)
http.request(URI("https://api.example.com#{path}"))
ensure
# Connection automatically released by pool
end
Authentication Token Lifetime
Long-lived connections with authentication tokens create token lifetime issues. A token valid at connection establishment might expire during the connection lifetime. Servers must verify tokens on each request, not just at connection establishment:
require 'net/http/persistent'
require 'json'
class AuthenticatedClient
def initialize(api_url)
@api_url = api_url
@http = Net::HTTP::Persistent.new(name: 'auth_client')
@token = nil
@token_expiry = nil
end
def request(path)
ensure_valid_token
uri = URI("#{@api_url}#{path}")
request = Net::HTTP::Get.new(uri)
request['Authorization'] = "Bearer #{@token}"
response = @http.request(uri, request)
# Handle token expiration
if response.code == '401'
refresh_token
request['Authorization'] = "Bearer #{@token}"
response = @http.request(uri, request)
end
response
end
private
def ensure_valid_token
if @token.nil? || Time.now >= @token_expiry
refresh_token
end
end
def refresh_token
# Obtain new token
# Set @token and @token_expiry
end
end
Information Leakage Through Connection Reuse
Persistent connections can leak information between requests if proper header management fails. Headers from previous requests might persist or credentials might accidentally apply to unintended requests:
# Problematic: headers persist across requests
http = Net::HTTP::Persistent.new(name: 'client')
http.override_headers['Authorization'] = 'Bearer secret-token'
# This authorization header applies to ALL requests
response1 = http.request(URI('https://api.example.com/public'))
response2 = http.request(URI('https://api.example.com/private'))
# Better: set headers per request
http = Net::HTTP::Persistent.new(name: 'client')
uri1 = URI('https://api.example.com/public')
request1 = Net::HTTP::Get.new(uri1)
response1 = http.request(uri1, request1)
uri2 = URI('https://api.example.com/private')
request2 = Net::HTTP::Get.new(uri2)
request2['Authorization'] = 'Bearer secret-token'
response2 = http.request(uri2, request2)
Server-Side Resource Exhaustion
Servers must protect against clients holding connections indefinitely. Without proper timeout enforcement, malicious or buggy clients can consume all available connection slots. Server administrators must configure:
# Nginx example
keepalive_timeout 65; # 65 seconds idle timeout
keepalive_requests 100; # Max 100 requests per connection
send_timeout 60; # 60 seconds to send response
client_body_timeout 60; # 60 seconds to receive request body
Clients should respect server connection policies and not attempt to circumvent timeout mechanisms.
TLS Session Resumption Security
TLS session resumption reduces TLS handshake overhead on new connections. However, session tickets or session IDs can be stolen and replayed. Servers should implement session ticket key rotation and forward secrecy through ephemeral key exchange.
Clients using persistent connections reduce exposure to session resumption attacks by minimizing new TLS handshakes. However, if connections close and reestablish frequently, session resumption becomes relevant.
Common Pitfalls
Stale Connection Detection
Clients often fail to detect when servers have closed idle connections. The client believes the connection remains alive but receives socket errors on the next request attempt:
# Problem: no stale connection handling
http = Net::HTTP::Persistent.new(name: 'client')
response = http.request(uri)
# Long delay...
response = http.request(uri) # Might fail with broken pipe
Solutions include:
- Catching connection errors and retrying with fresh connection
- Implementing connection health checks before reuse
- Setting client idle timeout shorter than server timeout
# Better: retry on connection errors
def request_with_retry(http, uri, max_retries: 2)
retries = 0
begin
http.request(uri)
rescue Errno::EPIPE, Errno::ECONNRESET, EOFError => e
retries += 1
if retries <= max_retries
http.shutdown
http = Net::HTTP::Persistent.new(name: 'client')
retry
else
raise
end
end
end
Incorrect Content-Length Handling
Failing to set correct Content-Length headers on requests causes connection confusion. The server cannot determine request boundaries and might wait indefinitely for more data:
# Problem: missing Content-Length
http = Net::HTTP.new('api.example.com', 443)
http.use_ssl = true
http.start
request = Net::HTTP::Post.new('/data')
request.body = '{"key": "value"}'
# Missing: request['Content-Length'] = request.body.bytesize
response = http.request(request) # Might hang
Ruby's Net::HTTP sets Content-Length automatically for most request types, but manual manipulation requires care:
# Correct: ensure Content-Length matches body
request = Net::HTTP::Post.new('/data')
body = '{"key": "value"}'
request.body = body
request['Content-Length'] = body.bytesize
Proxy Server Complications
HTTP proxies complicate keep-alive behavior. Some proxies do not support persistent connections or close connections despite keep-alive headers. The Connection header becomes hop-by-hop, applying only to the immediate connection:
Client <--keep-alive--> Proxy <--separate--> Server
The client maintains a persistent connection to the proxy, but the proxy might open fresh connections to the origin server. Clients cannot assume end-to-end connection reuse through proxies.
Additionally, the CONNECT method for HTTPS proxying establishes a tunnel but connection persistence applies to the tunnel itself, not the encapsulated TLS connection:
# Proxy configuration
http = Net::HTTP.new('api.example.com', 443, 'proxy.example.com', 8080)
http.use_ssl = true
http.start
# Connection to proxy persists
# Tunneled connection to api.example.com is separate
response1 = http.get('/resource1')
response2 = http.get('/resource2')
Connection Pool Starvation
Setting pool size too small creates contention when concurrent requests exceed available connections. Threads or processes block waiting for available connections:
# Problem: pool size 1, but 10 concurrent requests
http = Net::HTTP::Persistent.new(name: 'client', pool_size: 1)
threads = (1..10).map do |i|
Thread.new do
# All threads compete for single connection
uri = URI("https://api.example.com/data/#{i}")
response = http.request(uri)
puts "Thread #{i}: #{response.code}"
end
end
threads.each(&:join)
Monitor connection pool usage and adjust pool size based on observed concurrency patterns.
Ignoring Server Connection Limits
Clients that exceed server connection limits per IP address face rejected connections or rate limiting. Servers might return 503 Service Unavailable or close connections. Clients must:
- Respect server-specified connection limits (via headers or documentation)
- Implement backoff and retry logic
- Distribute load across multiple IP addresses when appropriate
Memory Leaks in Connection Pools
Improper connection pool implementations can leak connections that remain open indefinitely:
# Problem: connections not returned to pool
class LeakyPool
def initialize
@connections = []
end
def get_connection
@connections.detect(&:idle?) || create_connection
# Connection marked as in-use but never released
end
def create_connection
conn = Net::HTTP.new('api.example.com', 443)
conn.use_ssl = true
conn.start
@connections << conn
conn
end
end
Proper pools track connection state and implement explicit release mechanisms or automatic release through block APIs.
Misunderstanding HTTP/1.1 Pipelining
HTTP/1.1 pipelining allows sending multiple requests without waiting for responses, but most clients and servers disable this feature. Pipelining introduces complexity around request ordering and error handling. Ruby's standard HTTP clients do not support pipelining. Attempting to implement pipelining manually leads to protocol violations.
Reference
HTTP Headers
| Header | Direction | Description |
|---|---|---|
| Connection | Both | Controls connection persistence (keep-alive or close) |
| Keep-Alive | Both | Connection parameters: timeout and max requests |
| Content-Length | Both | Byte length of message body, required for boundary detection |
| Transfer-Encoding | Both | Indicates chunked encoding for unknown length messages |
Connection Header Values
| Value | HTTP Version | Meaning |
|---|---|---|
| keep-alive | HTTP/1.0 | Request or signal persistent connection support |
| close | HTTP/1.1 | Close connection after current request/response |
| (omitted) | HTTP/1.1 | Default: connection persists unless explicitly closed |
Keep-Alive Header Parameters
| Parameter | Type | Description |
|---|---|---|
| timeout | Integer | Seconds the connection remains idle before closing |
| max | Integer | Maximum requests allowed on this connection |
Common Timeout Values
| Timeout Type | Typical Range | Use Case |
|---|---|---|
| Connection establishment | 5-30 seconds | Time to complete TCP handshake and TLS handshake |
| Read timeout | 10-60 seconds | Time to receive complete response after request sent |
| Idle timeout (client) | 5-300 seconds | Client closes idle connection after this duration |
| Idle timeout (server) | 5-120 seconds | Server closes idle connection after this duration |
Ruby Library Comparison
| Library | Connection Pooling | Auto Reconnect | Configuration API |
|---|---|---|---|
| Net::HTTP | Manual | No | Low-level, verbose |
| Net::HTTP::Persistent | Automatic per-host | Yes | Simple, good defaults |
| Faraday | Adapter-dependent | Adapter-dependent | High-level, middleware-based |
| HTTP.rb | Built-in persistent mode | Yes | Fluent, chainable |
Connection Pool Configuration
| Parameter | Typical Value | Consideration |
|---|---|---|
| Pool size per host | 5-20 | Balance concurrency vs resources |
| Idle timeout | 5-60 seconds | Shorter for infrequent requests |
| Max lifetime | 300-3600 seconds | Force periodic reconnection |
| Connection timeout | 5-30 seconds | Network latency dependent |
| Read timeout | 30-120 seconds | Request complexity dependent |
Server Configuration Examples
| Server | Timeout Directive | Max Requests Directive |
|---|---|---|
| Nginx | keepalive_timeout | keepalive_requests |
| Apache | KeepAliveTimeout | MaxKeepAliveRequests |
| Puma | persistent_timeout | (not configurable) |
| Unicorn | timeout | (does not support keep-alive) |
Net::HTTP::Persistent Options
| Option | Type | Default | Description |
|---|---|---|---|
| name | String | required | Unique identifier for connection pool |
| pool_size | Integer | Process connections | Connections per host |
| idle_timeout | Integer | 5 | Seconds before closing idle connection |
| read_timeout | Integer | 30 | Response read timeout |
| open_timeout | Integer | 30 | Connection establishment timeout |
| socket_options | Array | [] | TCP socket options |
Error Codes Related to Connection Issues
| Error Class | Cause | Resolution |
|---|---|---|
| Errno::EPIPE | Broken pipe, server closed connection | Retry with new connection |
| Errno::ECONNRESET | Connection reset by peer | Retry with new connection |
| EOFError | Unexpected end of file, connection closed | Retry with new connection |
| Net::OpenTimeout | Connection establishment timeout | Check network, increase timeout |
| Net::ReadTimeout | Response read timeout | Check server load, increase timeout |
Connection States
| State | Description | Client Action |
|---|---|---|
| Establishing | TCP and TLS handshake in progress | Wait for completion |
| Active | Transmitting request or response data | Continue transmission |
| Idle | No active transmission, awaiting next request | Reuse or close based on policy |
| Closed | Connection terminated | Create new connection if needed |
| Stale | Server closed but client unaware | Detect on next request, reconnect |