DEV Community

Davide Santangelo
Davide Santangelo

Posted on

Building a Fast, Lightweight Web Server in Pure Ruby: A Complete Guide

Building your own web server might seem daunting, but Ruby's built-in libraries make it surprisingly straightforward. In this comprehensive guide, we'll create a fast, lightweight, and performant web server using nothing but pure Rubyβ€”no external gems or dependencies required.

Why Build Your Own Web Server?

Understanding how web servers work under the hood is crucial for any developer. By building one from scratch, you'll gain insights into:

  • HTTP protocol fundamentals
  • Socket programming
  • Request/response handling
  • Performance optimization techniques
  • Ruby's networking capabilities

Our final server will be capable of handling multiple concurrent connections, serving static files, and processing dynamic requests efficiently.

Prerequisites

Before we start, ensure you have:

  • Ruby 2.7 or higher installed
  • Basic understanding of HTTP protocol
  • Familiarity with Ruby syntax and concepts

Step 1: Understanding the Basics

A web server's core responsibility is listening for incoming HTTP requests and sending back appropriate responses. At its simplest, a web server:

  1. Binds to a port and listens for connections
  2. Accepts incoming client connections
  3. Reads HTTP requests
  4. Processes the requests
  5. Sends back HTTP responses
  6. Closes the connection

Let's start with the most basic implementation:

# basic_server.rb require 'socket' # Create a TCP server socket bound to port 3000 server = TCPServer.new(3000) puts "Server running on http://localhost:3000" loop do # Accept incoming connections client = server.accept # Read the request request = client.gets puts "Request: #{request}" # Send a simple HTTP response response = "HTTP/1.1 200 OK\r\n\r\nHello, World!" client.puts response # Close the connection client.close end 
Enter fullscreen mode Exit fullscreen mode

This basic server can handle one request at a time, but it's far from production-ready. Let's improve it step by step.

Step 2: Parsing HTTP Requests

HTTP requests follow a specific format. A typical GET request looks like:

GET /path HTTP/1.1 Host: localhost:3000 User-Agent: Mozilla/5.0... Accept: text/html... 
Enter fullscreen mode Exit fullscreen mode

Let's create a proper HTTP request parser:

# http_parser.rb class HTTPRequest attr_reader :method, :path, :version, :headers, :body def initialize(raw_request) lines = raw_request.split("\r\n") # Parse the request line request_line = lines.first @method, @path, @version = request_line.split(' ') if request_line # Parse headers @headers = {} header_lines = lines[1..-1] header_lines.each do |line| break if line.empty? # Empty line indicates end of headers key, value = line.split(': ', 2) @headers[key.downcase] = value if key && value end # Extract body (for POST requests) body_index = lines.index('') @body = body_index ? lines[(body_index + 1)..-1].join("\r\n") : '' end def get? @method == 'GET' end def post? @method == 'POST' end def valid? !@method.nil? && !@path.nil? && !@version.nil? end end 
Enter fullscreen mode Exit fullscreen mode

Step 3: Building HTTP Responses

Similarly, HTTP responses have a standard format:

HTTP/1.1 200 OK Content-Type: text/html Content-Length: 13 Hello, World! 
Enter fullscreen mode Exit fullscreen mode

Let's create a response builder:

# http_response.rb class HTTPResponse attr_accessor :status_code, :headers, :body def initialize(status_code = 200, body = '', headers = {}) @status_code = status_code @body = body @headers = { 'content-type' => 'text/html', 'content-length' => body.bytesize.to_s, 'connection' => 'close', 'server' => 'RubyServer/1.0' }.merge(headers) end def to_s status_text = status_message(@status_code) response = "HTTP/1.1 #{@status_code} #{status_text}\r\n" @headers.each do |key, value| response += "#{key.capitalize}: #{value}\r\n" end response += "\r\n#{@body}" response end private def status_message(code) case code when 200 then 'OK' when 201 then 'Created' when 404 then 'Not Found' when 405 then 'Method Not Allowed' when 500 then 'Internal Server Error' else 'Unknown' end end end 
Enter fullscreen mode Exit fullscreen mode

Step 4: Creating the Core Server Class

Now let's build our main server class that ties everything together:

# web_server.rb require 'socket' require 'uri' require 'cgi' class WebServer attr_reader :port, :host def initialize(port = 3000, host = 'localhost') @port = port @host = host @routes = {} @middleware = [] @static_paths = {} end def start @server = TCPServer.new(@host, @port) puts "πŸš€ Server running on http://#{@host}:#{@port}" puts "Press Ctrl+C to stop" trap('INT') { shutdown } loop do begin client = @server.accept handle_request(client) rescue => e puts "Error handling request: #{e.message}" ensure client&.close end end end def get(path, &block) add_route('GET', path, block) end def post(path, &block) add_route('POST', path, block) end def static(url_path, file_path) @static_paths[url_path] = file_path end def use(&middleware) @middleware << middleware end private def add_route(method, path, handler) @routes["#{method} #{path}"] = handler end def handle_request(client) raw_request = read_request(client) return unless raw_request request = HTTPRequest.new(raw_request) unless request.valid? send_response(client, HTTPResponse.new(400, 'Bad Request')) return end # Apply middleware context = { request: request, params: {} } @middleware.each { |middleware| middleware.call(context) } response = process_request(request, context) send_response(client, response) end def read_request(client) request_lines = [] # Read until we get the complete request while line = client.gets request_lines << line break if line.strip.empty? # Empty line indicates end of headers end request_lines.join rescue nil end def process_request(request, context) # Check for static files first static_response = handle_static_file(request) return static_response if static_response # Look for matching route route_key = "#{request.method} #{request.path}" handler = @routes[route_key] if handler begin result = handler.call(request, context[:params]) case result when String HTTPResponse.new(200, result) when Array status, body, headers = result HTTPResponse.new(status, body, headers || {}) when HTTPResponse result else HTTPResponse.new(200, result.to_s) end rescue => e puts "Error in route handler: #{e.message}" HTTPResponse.new(500, "Internal Server Error") end else HTTPResponse.new(404, "Not Found") end end def handle_static_file(request) return nil unless request.get? @static_paths.each do |url_path, file_path| if request.path.start_with?(url_path) relative_path = request.path[url_path.length..-1] full_path = File.join(file_path, relative_path) if File.exist?(full_path) && File.file?(full_path) content = File.read(full_path) content_type = guess_content_type(full_path) return HTTPResponse.new(200, content, { 'content-type' => content_type }) end end end nil end def guess_content_type(file_path) extension = File.extname(file_path).downcase case extension when '.html', '.htm' then 'text/html' when '.css' then 'text/css' when '.js' then 'application/javascript' when '.json' then 'application/json' when '.png' then 'image/png' when '.jpg', '.jpeg' then 'image/jpeg' when '.gif' then 'image/gif' when '.svg' then 'image/svg+xml' when '.txt' then 'text/plain' else 'application/octet-stream' end end def send_response(client, response) client.write(response.to_s) rescue # Client might have disconnected end def shutdown puts "\nπŸ›‘ Shutting down server..." @server&.close exit end end 
Enter fullscreen mode Exit fullscreen mode

Step 5: Adding Concurrency Support

Our current server handles one request at a time, which is inefficient. Let's add threading support to handle multiple concurrent requests:

# Add this method to the WebServer class def start_threaded @server = TCPServer.new(@host, @port) puts "πŸš€ Threaded server running on http://#{@host}:#{@port}" puts "Press Ctrl+C to stop" trap('INT') { shutdown } loop do begin client = @server.accept # Handle each request in a separate thread Thread.new(client) do |client_connection| begin handle_request(client_connection) rescue => e puts "Error in thread: #{e.message}" ensure client_connection&.close end end rescue => e puts "Error accepting connection: #{e.message}" end end end 
Enter fullscreen mode Exit fullscreen mode

Step 6: Performance Optimizations

Let's add several performance improvements:

Connection Pooling and Keep-Alive

# Enhanced version with keep-alive support def handle_request_with_keepalive(client) loop do raw_request = read_request(client) break unless raw_request request = HTTPRequest.new(raw_request) break unless request.valid? context = { request: request, params: {} } @middleware.each { |middleware| middleware.call(context) } response = process_request(request, context) # Check if client wants to keep connection alive if should_keep_alive?(request, response) response.headers['connection'] = 'keep-alive' send_response(client, response) else response.headers['connection'] = 'close' send_response(client, response) break end end end private def should_keep_alive?(request, response) # Keep alive if client requests it and response is successful connection_header = request.headers['connection'] connection_header&.downcase == 'keep-alive' && response.status_code < 400 end 
Enter fullscreen mode Exit fullscreen mode

Request Parsing Optimization

# Optimized HTTPRequest class class HTTPRequest MAX_REQUEST_SIZE = 8192 # 8KB limit def self.parse_fast(raw_request) return nil if raw_request.bytesize > MAX_REQUEST_SIZE lines = raw_request.split("\r\n", -1) return nil if lines.empty? # Quick validation request_line_parts = lines[0].split(' ', 3) return nil if request_line_parts.size < 3 new(raw_request) end # ... rest of the class remains the same end 
Enter fullscreen mode Exit fullscreen mode

Step 7: Creating a Complete Example Application

Let's put it all together with a practical example:

#!/usr/bin/env ruby # my_app.rb require_relative 'web_server' require_relative 'http_parser' require_relative 'http_response' # Create the server app = WebServer.new(3000, 'localhost') # Add middleware for logging app.use do |context| request = context[:request] puts "#{Time.now} - #{request.method} #{request.path}" end # Add middleware for basic authentication (example) app.use do |context| request = context[:request] # Skip auth for public paths return if request.path.start_with?('/public') auth_header = request.headers['authorization'] if auth_header && auth_header.start_with?('Basic ') # Simple base64 decode (for demo only - use proper auth in production) encoded = auth_header.split(' ', 2)[1] decoded = encoded.unpack('m0')[0] rescue '' username, password = decoded.split(':', 2) context[:user] = username if username == 'admin' && password == 'secret' end end # Serve static files app.static('/public', './public') # Define routes app.get '/' do |request, params| <<~HTML <!DOCTYPE html> <html> <head> <title>Ruby Web Server</title> <style> body { font-family: Arial, sans-serif; margin: 40px; } .container { max-width: 800px; margin: 0 auto; } .nav { margin-bottom: 20px; } .nav a { margin-right: 10px; padding: 5px 10px; background: #007acc; color: white; text-decoration: none; } </style> </head> <body> <div class="container"> <h1>πŸš€ Welcome to Ruby Web Server!</h1> <div class="nav"> <a href="/">Home</a> <a href="/about">About</a> <a href="/api/status">API Status</a> <a href="/form">Form Example</a> </div> <p>This is a lightweight web server built with pure Ruby!</p> <p>Features:</p> <ul> <li>βœ… HTTP/1.1 support</li> <li>βœ… Static file serving</li> <li>βœ… Dynamic routing</li> <li>βœ… Middleware support</li> <li>βœ… Concurrent request handling</li> <li>βœ… Basic authentication</li> </ul> </div> </body> </html>  HTML end app.get '/about' do |request, params| [200, "About Page - Built with ❀️ and Ruby", { 'content-type' => 'text/plain' }] end app.get '/api/status' do |request, params| status = { server: 'RubyServer/1.0', status: 'running', timestamp: Time.now.iso8601, uptime: Process.clock_gettime(Process::CLOCK_MONOTONIC).to_i } [200, status.to_json, { 'content-type' => 'application/json' }] end app.get '/form' do |request, params| <<~HTML <!DOCTYPE html> <html> <head> <title>Form Example</title> <style>body { font-family: Arial, sans-serif; margin: 40px; }</style> </head> <body> <h1>Form Example</h1> <form method="POST" action="/form"> <p> <label>Name: <input type="text" name="name" required></label> </p> <p> <label>Email: <input type="email" name="email" required></label> </p> <p> <label>Message:<br> <textarea name="message" rows="4" cols="50" required></textarea> </label> </p> <p> <button type="submit">Submit</button> </p> </form> <a href="/">← Back to Home</a> </body> </html>  HTML end app.post '/form' do |request, params| # Parse form data form_data = CGI::parse(request.body) name = form_data['name']&.first email = form_data['email']&.first message = form_data['message']&.first response_html = <<~HTML <!DOCTYPE html> <html> <head> <title>Form Submitted</title> <style>body { font-family: Arial, sans-serif; margin: 40px; }</style> </head> <body> <h1>βœ… Form Submitted Successfully!</h1> <p><strong>Name:</strong> #{CGI.escapeHTML(name || '')}</p> <p><strong>Email:</strong> #{CGI.escapeHTML(email || '')}</p> <p><strong>Message:</strong><br>#{CGI.escapeHTML(message || '').gsub("\n", "<br>")}</p> <a href="/form">← Submit Another</a> | <a href="/">Home</a> </body> </html>  HTML [200, response_html] end # Catch-all route for API endpoints app.get '/api/*' do |request, params| error = { error: 'Not Found', message: 'API endpoint not found', path: request.path } [404, error.to_json, { 'content-type' => 'application/json' }] end # Start the server with threading support app.start_threaded 
Enter fullscreen mode Exit fullscreen mode

Step 8: Running and Testing Your Server

  1. Save all the code files in the same directory
  2. Create a public directory for static files:
mkdir public echo "<h1>Static File</h1><p>This is served statically!</p>" > public/test.html 
Enter fullscreen mode Exit fullscreen mode
  1. Run your server:
ruby my_app.rb 
Enter fullscreen mode Exit fullscreen mode
  1. Test it in your browser:
    • http://localhost:3000/ - Home page
    • http://localhost:3000/about - About page
    • http://localhost:3000/api/status - JSON API
    • http://localhost:3000/form - Form example
    • http://localhost:3000/public/test.html - Static file

Step 9: Performance Benchmarking

Let's add a simple benchmark route to test our server's performance:

app.get '/benchmark' do |request, params| start_time = Time.now # Simulate some work 1000.times { Math.sqrt(rand(1000)) } end_time = Time.now processing_time = ((end_time - start_time) * 1000).round(2) { processing_time_ms: processing_time, timestamp: start_time.iso8601, random_number: rand(1000) }.to_json end 
Enter fullscreen mode Exit fullscreen mode

Step 10: Production Considerations

While our server is functional, consider these improvements for production use:

Security Enhancements

# Add security headers middleware app.use do |context| request = context[:request] context[:security_headers] = { 'x-frame-options' => 'DENY', 'x-content-type-options' => 'nosniff', 'x-xss-protection' => '1; mode=block', 'referrer-policy' => 'strict-origin-when-cross-origin' } end 
Enter fullscreen mode Exit fullscreen mode

Error Handling

def handle_request_safely(client) begin handle_request(client) rescue => e error_response = HTTPResponse.new(500, 'Internal Server Error') send_response(client, error_response) puts "Server error: #{e.message}" puts e.backtrace.first(5) end end 
Enter fullscreen mode Exit fullscreen mode

Resource Limits

class WebServer MAX_CONCURRENT_CONNECTIONS = 100 CONNECTION_TIMEOUT = 30 def initialize(port = 3000, host = 'localhost') # ... existing code ... @connection_count = 0 @connection_mutex = Mutex.new end def start_with_limits # ... implement connection limiting ... end end 
Enter fullscreen mode Exit fullscreen mode

Conclusion

Congratulations! You've built a fully functional web server in pure Ruby. This server includes:

  • βœ… HTTP/1.1 protocol support
  • βœ… GET and POST request handling
  • βœ… Static file serving with proper MIME types
  • βœ… Dynamic routing system
  • βœ… Middleware architecture
  • βœ… Concurrent request processing
  • βœ… Basic authentication support
  • βœ… JSON API endpoints
  • βœ… Form processing
  • βœ… Error handling

Key Takeaways

  1. Ruby's Standard Library is Powerful: We built everything using only Ruby's built-in libraries
  2. HTTP is Simple: The protocol itself is straightforward text-based communication
  3. Concurrency Matters: Threading dramatically improves performance
  4. Architecture is Important: Clean separation of concerns makes the code maintainable
  5. Performance Optimization: Small changes can have big impacts on server performance

Next Steps

To further enhance your server, consider:

  • Adding HTTPS/TLS support using OpenSSL
  • Implementing HTTP/2 support
  • Adding request/response compression
  • Creating a plugin system
  • Adding database connectivity
  • Implementing caching mechanisms
  • Adding WebSocket support

This foundation gives you a solid understanding of how web servers work and provides a starting point for more advanced features. Whether you use this in production or just for learning, you now have deep insight into the mechanics of web server operation.

Top comments (0)