Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Lie You've Been Living

Let's start with a confession: Rails is a callable.

Not metaphorically. Not "if you squint at it." Literally, mechanically, by definition — Rails is an object that responds to call. You pass it a hash, it returns an array. That's the whole thing. The routing DSL, the ActiveRecord integration, the asset pipeline, the mailers — all of it exists so that one call method can do something useful.

Here's the proof:

# config/environment.rb loads your Rails app
require_relative '../config/environment'

# This is your entire Rails application
app = Rails.application

# It responds to call
app.respond_to?(:call) # => true

# Call it directly with a minimal Rack environment
env = {
  'REQUEST_METHOD' => 'GET',
  'PATH_INFO'      => '/',
  'rack.input'     => StringIO.new,
  'SERVER_NAME'    => 'localhost',
  'SERVER_PORT'    => '3000',
  'HTTP_VERSION'   => 'HTTP/1.1',
}

status, headers, body = app.call(env)

puts status   # 200
puts headers  # {"Content-Type" => "text/html; charset=utf-8", ...}
body.each { |chunk| print chunk } # your HTML

Run that in a Rails console. It works. No HTTP required, no browser, no WEBrick. Just a hash in and an array out.

This is not a party trick. This is the entire basis of Ruby web development, and understanding it changes how you read framework code, debug middleware issues, and make architectural decisions.

Why This Matters

You've probably been writing Ruby web applications for a while. You know how to define routes, render templates, write controllers, handle authentication. You're productive. The framework handles the HTTP layer and you work at the application layer, which is exactly the correct division of labor in production.

The problem is that "the framework handles it" is a sentence that stops you from understanding what's actually happening. When something breaks in a way that the framework's error messages don't explain clearly, you're stuck. When you need to write middleware, you're cargo-culting examples. When you're evaluating whether to use Sinatra or Roda or some other non-Rails framework, you're guessing.

The Rack specification — which is what makes all of this work — is simple enough to explain completely in one chapter. The HTTP protocol that sits beneath it is simple enough to understand in an afternoon. Once you understand both, the entire ecosystem of Ruby web frameworks becomes readable rather than mysterious.

What This Book Is

This book is about three things:

  1. Rack: The protocol that unifies Ruby web development. We'll read the spec, build apps against it directly, write our own server, and implement middleware from scratch.

  2. Roda: A web framework that takes Rack seriously. Where Rails uses Rack as a compatibility layer (something your application sits on top of), Roda uses Rack as a foundation (something your application is built out of). The distinction matters.

  3. The gap between them: What frameworks actually add, why those additions exist, and when you want them versus when you don't.

What This Book Is Not

This is not a Rails tutorial, a Sinatra tutorial, or a Roda tutorial in the conventional sense. There are plenty of those. This is a book about the layer beneath those frameworks, with enough framework coverage to make the comparison meaningful.

This is also not a "write everything from scratch" manifesto. Frameworks exist because they solve real problems, and Roda is genuinely excellent at what it does. The goal isn't to convince you to abandon your tools; it's to make you fluent enough in the underlying mechanics that you can use your tools with full understanding of what they're doing.

Prerequisites

You should know Ruby reasonably well — blocks, modules, objects, the basics of metaprogramming. You should have written at least one web application in Ruby, probably with Rails or Sinatra. You should be comfortable at the command line.

You do not need to know anything about Rack, Roda, or HTTP internals. We'll cover all of that.

A Note on the Code

Every code example in this book runs. If an example requires a gem, it says so. If it requires Ruby 3.x, it says so. The examples are not simplified pseudocode — they're actual Ruby that does actual things.

The simplest Rack application in this book is nine lines. The server we build from scratch is about a hundred. Neither of these is a toy, even though neither is production-ready. They're instructive, which is more useful.

Acknowledgements

Thanks to Georgiy Treyvus for coming up with the idea for this book.

Let's start by talking about what's actually happening on the wire.

HTTP Is Just Text

Before we talk about Rack, before we talk about Ruby, we need to talk about what's actually going on between the browser and your application. Because once you see it, you can't unsee it, and everything you've been doing will make more sense.

Open a terminal. We're going to make an HTTP request without a browser, without a library, without anything except nc (netcat) and our fingers.

The Actual Wire

$ printf "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n" | nc example.com 80

You'll see something like:

HTTP/1.1 200 OK
Content-Encoding: gzip
Accept-Ranges: bytes
Age: 604476
Cache-Control: max-age=604800
Content-Type: text/html; charset=UTF-8
Date: Mon, 19 Feb 2026 12:00:00 GMT
Etag: "3147526947+gzip"
Expires: Mon, 26 Feb 2026 12:00:00 GMT
Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
Server: ECS (nyb/1D2E)
Vary: Accept-Encoding
X-Cache: HIT
Content-Length: 648

<!doctype html>
...

That's it. That's HTTP. Text goes in, text comes out. The format is rigid but the transport is a TCP socket, which is just a reliable stream of bytes. HTTP doesn't care that those bytes happen to be ASCII text. The convention is that they are.

The Request Format

An HTTP/1.1 request looks like this:

METHOD /path HTTP/1.1\r\n
Header-Name: header value\r\n
Another-Header: its value\r\n
\r\n
optional body here

The parts:

  • Request line: METHOD PATH HTTP-VERSION — all on one line, \r\n terminated
  • Headers: Name: Value pairs, one per line, \r\n terminated
  • Blank line: A \r\n on its own signals end of headers
  • Body: Optional. Present for POST/PUT, usually absent for GET. Length is specified in Content-Length.

Let's build one by hand:

require 'socket'

# Open a TCP connection
socket = TCPSocket.new('httpbin.org', 80)

# Write a valid HTTP/1.1 request
request = [
  "GET /get HTTP/1.1",
  "Host: httpbin.org",
  "Accept: application/json",
  "Connection: close",
  "",  # blank line = end of headers
  ""   # body (empty)
].join("\r\n")

socket.write(request)

# Read the response
response = socket.read
socket.close

puts response

Run that. You'll get back a real HTTP response with JSON body. No gems, no frameworks. Just a TCP socket and text.

The Response Format

The response format mirrors the request:

HTTP/1.1 STATUS_CODE REASON_PHRASE\r\n
Header-Name: header value\r\n
Another-Header: its value\r\n
\r\n
body content here

The status line is HTTP-VERSION STATUS-CODE REASON-PHRASE. The status code is what you check in your application code and what browsers act on. The reason phrase ("OK", "Not Found", "Internal Server Error") is informational and largely ignored by machines.

# Parse an HTTP response by hand
response_text = <<~HTTP
  HTTP/1.1 200 OK\r
  Content-Type: text/plain\r
  Content-Length: 13\r
  \r
  Hello, World!
HTTP

lines = response_text.split("\r\n")

# First line is the status line
status_line = lines.shift
version, code, *reason = status_line.split(' ')
status_code = code.to_i

puts "Version: #{version}"  # HTTP/1.1
puts "Status:  #{status_code}"  # 200
puts "Reason:  #{reason.join(' ')}"  # OK

# Headers follow until the blank line
headers = {}
while (line = lines.shift) && !line.empty?
  name, value = line.split(': ', 2)
  headers[name] = value
end

puts "Headers: #{headers.inspect}"
# {"Content-Type" => "text/plain", "Content-Length" => "13"}

# Rest is body
body = lines.join("\r\n")
puts "Body: #{body}"  # Hello, World!

What Servers Actually Do

A web server's job, stripped to its core:

  1. Listen on a TCP port (usually 80 or 443)
  2. Accept a connection
  3. Read bytes until you have a complete HTTP request
  4. Parse the request into a structured format
  5. Do something with it (your application code runs here)
  6. Format the response back into HTTP text
  7. Write it to the socket
  8. Close the connection (or keep it open for HTTP/1.1 keep-alive)

Step 5 is the only step that varies between applications. Steps 1-4 and 6-8 are the same for every web application ever written. Rack is what formalizes the handoff at step 5.

What Headers Are Actually Doing

Headers are metadata about the request or response. Nothing more. They're just key-value pairs that tell the other side how to interpret what it's receiving.

Some important ones:

Request headers you should know:

  • Host — which domain the client wants (required in HTTP/1.1, because one IP can serve many domains)
  • Accept — what content types the client can handle
  • Content-Type — what format the request body is in (important for POST)
  • Content-Length — how many bytes in the body
  • Cookie — cookies, serialized as name=value; name2=value2
  • Authorization — authentication credentials

Response headers you should know:

  • Content-Type — what format the body is in, e.g. text/html; charset=utf-8
  • Content-Length — how many bytes in the body (so the client knows when it's done)
  • Set-Cookie — asks the client to store a cookie
  • Location — used with 301/302 redirects to specify where to go
  • Cache-Control — caching instructions

Your framework sets most of these for you. But it's worth knowing they're just text in a predictable format.

POST Bodies

When you submit a form, the browser sends a POST request with the form data in the body. The format depends on the Content-Type header:

application/x-www-form-urlencoded (the default):

name=Alice&age=30&city=Auckland

multipart/form-data (for file uploads):

--boundary123
Content-Disposition: form-data; name="name"

Alice
--boundary123
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Content-Type: image/jpeg

[binary file data here]
--boundary123--

application/json (for API clients):

{"name": "Alice", "age": 30, "city": "Auckland"}

When Rails gives you params[:name], it has parsed one of these formats. When it fails in production with a cryptic body-parsing error, now you know where to look.

The Moment Where It Clicks

Here's the thing: HTTP is a protocol designed in 1991 and finalized in 1996. It was designed by people who expected it to be implemented in C and read by humans for debugging. The fact that it's text is a feature, not a coincidence.

This is why you can debug HTTP with nc, with curl -v, with browser DevTools. This is why log lines make sense. This is why you can write a minimal HTTP server in a hundred lines of Ruby (we will).

HTTP/2 and HTTP/3 are binary protocols, which is why you can't nc them as easily. But HTTP/1.1 is still everywhere, and Rack was designed around it.

Putting It Together: A Minimal HTTP Interaction in Ruby

require 'socket'

# Server side: accept one request and respond
server = TCPServer.new(2345)
puts "Listening on :2345"

Thread.new do
  client = server.accept

  # Read the request line
  request_line = client.gets
  puts "Got: #{request_line.chomp}"

  # Read headers until blank line
  headers = {}
  while (line = client.gets.chomp) && !line.empty?
    name, value = line.split(': ', 2)
    headers[name] = value
  end

  # Build a response
  body = "Hello from a real HTTP server!\n"
  response = [
    "HTTP/1.1 200 OK",
    "Content-Type: text/plain",
    "Content-Length: #{body.bytesize}",
    "Connection: close",
    "",
    body
  ].join("\r\n")

  client.write(response)
  client.close
  server.close
end

# Client side: make a request
sleep(0.1) # give server a moment to start

require 'socket'
socket = TCPSocket.new('localhost', 2345)
socket.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
puts socket.read
socket.close

Save that as http_demo.rb and run it:

$ ruby http_demo.rb
Listening on :2345
Got: GET / HTTP/1.1
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 31
Connection: close

Hello from a real HTTP server!

You just wrote an HTTP server and client from scratch. It handles exactly one request. It has no routing. It ignores the path. But it speaks valid HTTP and it works. Everything that comes after this chapter — Rack, Roda, Rails — is elaborating on this foundation.

The next question is: how do you plug your Ruby code into this in a standardized way, so that your code can run on any HTTP server, and any HTTP server can run your code? That's what Rack solves.

What Rails and Sinatra Are Actually Doing

Now that you know HTTP is text, let's talk about what happens between "the server receives bytes" and "your route handler runs." This is the part that frameworks describe as magic. It isn't.

The Stack

When an HTTP request hits a Rails or Sinatra application, it passes through several layers before your code sees it:

  1. The HTTP server (Puma, WEBrick, Unicorn) accepts a TCP connection and parses raw HTTP text into a structured Ruby hash.
  2. That hash gets passed through Rack middleware — a chain of objects that can inspect, modify, or halt the request before it reaches your application.
  3. Your application receives the (possibly modified) hash, runs your route handler, and returns a status code, headers, and body.
  4. The response travels back up through the middleware chain.
  5. The server serializes the response into HTTP text and writes it to the socket.

You control step 3. Rack owns step 2. The server owns steps 1, 4, and 5.

Here's the thing: steps 1 and 3 are what vary between server choices and framework choices. Steps 2 and 4 — the middleware chain — use the same protocol regardless of whether you're using Rails, Sinatra, Roda, or a handwritten Rack app.

Let's Look at Rails

# Gemfile
gem 'rails'

# config.ru (every Rails app has this)
require_relative 'config/environment'
run Rails.application

That run Rails.application line is the whole story. run is a Rack method that calls your object's call method for every request. Rails.application is a callable.

We can inspect what Rails actually does:

# In a Rails console
middleware_stack = Rails.application.middleware

middleware_stack.each do |middleware|
  puts middleware.inspect
end

You'll see something like:

#<ActionDispatch::HostAuthorization ...>
#<Rack::Sendfile ...>
#<ActionDispatch::Static ...>
#<ActionDispatch::Executor ...>
#<ActiveSupport::Cache::Strategy::LocalCache::Middleware ...>
#<Rack::Runtime ...>
#<Rack::MethodOverride ...>
#<ActionDispatch::RequestId ...>
#<ActionDispatch::RemoteIp ...>
#<Sprockets::Rails::QuietAssets ...>
#<Rails::Rack::Logger ...>
#<ActionDispatch::ShowExceptions ...>
#<ActionDispatch::DebugExceptions ...>
#<ActionDispatch::ActionableExceptions ...>
#<ActionDispatch::Reloader ...>
#<ActionDispatch::Callbacks ...>
#<ActiveRecord::Migration::CheckPending ...>
#<ActionDispatch::Cookies ...>
#<ActionDispatch::Session::CookieStore ...>
#<ActionDispatch::Flash ...>
#<ActionDispatch::ContentSecurityPolicy::Middleware ...>
#<ActionDispatch::PermissionsPolicy::Middleware ...>
#<Rack::Head ...>
#<Rack::ConditionalGet ...>
#<Rack::ETag ...>
#<Rack::TempfileReaper ...>

That's over twenty pieces of middleware wrapping your application before a single request reaches your router. Most of them are doing something useful. Rack::MethodOverride is what makes _method=DELETE in form submissions work. ActionDispatch::Session::CookieStore is where sessions come from. Rack::ETag generates ETags for conditional GET responses.

At the very bottom of that stack is your router, which dispatches to controllers, which call your code. The router is also just a callable.

Let's Look at Sinatra

Sinatra is simpler, which makes it easier to see the structure:

require 'sinatra/base'

class MyApp < Sinatra::Base
  get '/' do
    'Hello, World!'
  end
end

Sinatra::Base is a Rack application. It has a call method. When you write:

get '/' do
  'Hello, World!'
end

...you're adding a route to a routing table that lives inside the call method. The call method looks at the env hash, extracts the HTTP method and path, finds a matching route, and calls your block.

Here's a rough but accurate implementation of what Sinatra's routing core does:

class TinySinatra
  def initialize
    @routes = {}
  end

  def get(path, &handler)
    @routes[['GET', path]] = handler
  end

  def post(path, &handler)
    @routes[['POST', path]] = handler
  end

  def call(env)
    method = env['REQUEST_METHOD']
    path   = env['PATH_INFO']

    handler = @routes[[method, path]]

    if handler
      body = handler.call
      [200, {'Content-Type' => 'text/html'}, [body]]
    else
      [404, {'Content-Type' => 'text/plain'}, ['Not Found']]
    end
  end
end

That's not a joke. Sinatra's actual implementation is more sophisticated (regex matching, parameter extraction, before/after filters, error handling, template rendering), but the structure is exactly this: a hash of routes, a call method that looks things up in the hash.

Let's verify it works:

app = TinySinatra.new
app.get('/') { 'Hello!' }
app.get('/about') { 'About page.' }

# Simulate what a Rack server does
env = {
  'REQUEST_METHOD' => 'GET',
  'PATH_INFO'      => '/',
  'rack.input'     => StringIO.new,
}

status, headers, body = app.call(env)
puts status    # 200
puts body      # ["Hello!"]

env['PATH_INFO'] = '/missing'
status, headers, body = app.call(env)
puts status    # 404

What Frameworks Actually Add

Now we can be precise about what you're paying for when you use a framework:

Rails adds:

  • A routing DSL that handles parameters, constraints, and named routes
  • Controllers with before/after actions, strong parameters, response helpers
  • ActiveRecord (this alone is most of the value proposition)
  • View rendering with template engines and layouts
  • Asset pipeline
  • A massive middleware stack with sensible defaults
  • Conventions that allow code generation and eliminate boilerplate
  • A very large community and ecosystem

Sinatra adds:

  • A routing DSL (simpler than Rails's)
  • Filters (before/after handlers)
  • Template rendering
  • A small, optional middleware stack
  • Much less convention, more flexibility

What neither adds, because Rack already provides it:

  • The protocol for receiving requests and returning responses
  • The ability to run on any conforming server
  • The middleware interface

This is why you can swap Puma for Unicorn without changing your application. This is why you can write middleware that works in both Rails and Sinatra apps. This is why a Rack app can be embedded inside a Rails app, and a Rails app can be mounted inside a Rack app. They all speak the same protocol.

The Middleware Chain Is Composable

Here's something you can do in Rails that will make the structure visible:

# config/application.rb
module MyApp
  class Application < Rails::Application
    # Add our own middleware at the front of the stack
    config.middleware.use LoggingMiddleware

    # Add middleware after a specific existing one
    config.middleware.insert_after ActionDispatch::Flash, CustomMiddleware

    # Remove middleware we don't need
    config.middleware.delete Rack::Runtime
  end
end

And here's a middleware that you could add to Rails, Sinatra, or a bare Rack app without modification:

class LoggingMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    start = Time.now
    status, headers, body = @app.call(env)
    elapsed = Time.now - start

    puts "[#{status}] #{env['REQUEST_METHOD']} #{env['PATH_INFO']} (#{elapsed.round(4)}s)"

    [status, headers, body]
  end
end

This is not framework-specific code. It's Rack code. It works because both Rails and Sinatra are Rack applications, and this is a Rack middleware.

The Call Stack

When a request comes in, execution looks like this:

Server.call(env)
  LoggingMiddleware.call(env)
    Rack::Session::Cookie.call(env)
      Rack::MethodOverride.call(env)
        YourApplication.call(env)
          # Your route runs here
          # Returns [200, headers, body]
        # MethodOverride gets [200, headers, body]
      # Session middleware gets [200, headers, body]
    # Logging middleware gets [200, headers, body]
  # Server sends the response

Each layer wraps the next. Each layer can modify the request env before passing it down, and modify the response before passing it up. The pattern is: wrap the inner app, call it, do something with the result.

This is just function composition, with objects instead of functions. If you've worked with function pipelines in Elixir or middleware in Express.js, it's the same idea.

Why This Matters for You

When something goes wrong in a web application, it happens at one of these layers:

  • The server layer: connection issues, SSL errors, timeout behavior
  • The middleware layer: session corruption, cookie issues, CSRF failures, content encoding problems
  • The routing layer: 404s, parameter parsing, path matching
  • The application layer: your actual code

When you don't know these layers exist, every bug is mysterious. When you do, you can narrow it down quickly. Is the bug in your code, or is it in the middleware below your code? Add a middleware that logs the env before your code runs. Is the response wrong, or is a middleware above you rewriting it? Log the response after your code runs.

The tools for this kind of debugging are available to you the moment you understand that your application is wrapped in a stack of callables.

That's what frameworks are doing. They're arranging callables in a useful order and providing defaults that most applications need. The next step is to look at the protocol that makes all of this possible.

The Rack Spec (It Fits on a Napkin)

The Rack specification defines the interface between Ruby web servers and Ruby web applications. It was designed by Christian Neukirchen in 2007, and despite fifteen years of Ruby web development since then, it hasn't needed fundamental changes. Simple things tend to be durable.

Here's the spec:

A Rack application is a Ruby object (not a class) that responds to call. It takes exactly one argument, the environment, and returns a non-frozen Array of exactly three values: the status, the headers, and the body.

That's it. Three rules:

  1. Your app is an object (not a class — an instance)
  2. It has a call method that takes an environment hash
  3. call returns [status, headers, body]

Everything else is elaboration.

The Environment Hash

The environment (called env by convention) is a Ruby Hash containing information about the current request. The server populates it. Your application reads from it.

The Rack spec requires these keys:

KeyTypeDescription
REQUEST_METHODString"GET", "POST", "PUT", etc.
SCRIPT_NAMEStringMount point of the application (often "")
PATH_INFOStringPath component of the URL, e.g. "/users/42"
QUERY_STRINGStringQuery string without ?, e.g. "page=2&sort=name"
SERVER_NAMEStringHostname, e.g. "example.com"
SERVER_PORTStringPort as a string, e.g. "80"
HTTP_*StringHTTP request headers, upcased with hyphens replaced by underscores
rack.versionArrayRack version, e.g. [1, 3]
rack.url_schemeString"http" or "https"
rack.inputIO-likeThe request body, readable via read, gets, each
rack.errorsIO-likeError stream (usually $stderr)
rack.multithreadBooleanWhether the server is multi-threaded
rack.multiprocessBooleanWhether the server is multi-process
rack.run_onceBooleanWhether this process will handle only one request
rack.hijack?BooleanWhether the server supports connection hijacking

In practice, you'll mostly use REQUEST_METHOD, PATH_INFO, QUERY_STRING, and HTTP_* headers. The rack.input stream is important for POST bodies.

Some real-world additions that aren't in the base spec but you'll encounter:

  • rack.session — your session data (added by session middleware)
  • rack.logger — a logger (added by logger middleware)
  • action_dispatch.* — Rails-specific additions
  • HTTP_COOKIE — cookies as a string ("name=value; other=thing")

The Response Array

The response is [status, headers, body]:

Status: An integer HTTP status code. 200, 201, 301, 404, 500. That's it.

status = 200

Headers: A Hash of response headers. Keys are strings. Values are strings.

headers = {
  'Content-Type'  => 'text/html; charset=utf-8',
  'Content-Length' => '13',
}

Body: An object that responds to each, yielding string chunks. Usually an Array of strings, sometimes an IO object for streaming.

body = ["Hello, World!"]

# Or for streaming:
body = SomeObject.new
def body.each
  yield "chunk 1"
  yield "chunk 2"
  yield "chunk 3"
end

The full minimal response:

[200, {'Content-Type' => 'text/plain'}, ['Hello, World!']]

The Simplest Possible Rack App

# hello.rb
require 'rack'

app = lambda do |env|
  [200, {'Content-Type' => 'text/plain'}, ['Hello, World!']]
end

Rack::Handler::WEBrick.run app, Port: 9292

Run it:

$ gem install rack
$ ruby hello.rb
[2026-02-19 12:00:00] INFO  WEBrick 1.7.0
[2026-02-19 12:00:00] INFO  ruby 3.3.0
[2026-02-19 12:00:00] INFO  WEBrick::HTTPServer#start: pid=12345 port=9292

Then:

$ curl http://localhost:9292
Hello, World!

The lambda is a Rack application. It takes env, returns [status, headers, body]. The spec is satisfied.

The config.ru Format

Most Ruby web servers look for a config.ru file in the current directory. It's processed by Rack::Builder, which gives you a small DSL:

# config.ru

require_relative 'app'

use MyMiddleware           # add middleware to the stack
use AnotherMiddleware, option: 'value'

run MyApplication.new      # the innermost app
  • use adds a middleware layer
  • run sets the inner application
  • map mounts apps at different paths (more on this later)

You can run any config.ru with:

$ rackup            # uses config.ru in current directory
$ rackup myapp.ru   # uses a specific file

rackup figures out the best available server and starts it.

Reading the Environment

Here's a Rack app that echoes back what it received:

# echo.ru
require 'json'

app = lambda do |env|
  # Collect interesting parts of the env
  info = {
    method:       env['REQUEST_METHOD'],
    path:         env['PATH_INFO'],
    query_string: env['QUERY_STRING'],
    headers:      env.select { |k, _| k.start_with?('HTTP_') },
  }

  # Read the body if there is one
  body = env['rack.input'].read
  info[:body] = body unless body.empty?

  response_body = JSON.pretty_generate(info)

  [
    200,
    {
      'Content-Type'   => 'application/json',
      'Content-Length' => response_body.bytesize.to_s,
    },
    [response_body]
  ]
end

run app
$ rackup echo.ru &
$ curl -X POST http://localhost:9292/test?foo=bar \
  -H 'Content-Type: application/json' \
  -d '{"hello": "world"}'
{
  "method": "POST",
  "path": "/test",
  "query_string": "foo=bar",
  "headers": {
    "HTTP_HOST": "localhost:9292",
    "HTTP_USER_AGENT": "curl/7.88.1",
    "HTTP_ACCEPT": "*/*",
    "HTTP_CONTENT_TYPE": "application/json",
    "HTTP_CONTENT_LENGTH": "18"
  },
  "body": "{\"hello\": \"world\"}"
}

Notice that Content-Type in the request becomes HTTP_CONTENT_TYPE in the env. The transformation is: HTTP_ prefix + uppercase + hyphens become underscores. The Host header becomes HTTP_HOST. User-Agent becomes HTTP_USER_AGENT.

There are two exceptions: Content-Type is available as both HTTP_CONTENT_TYPE and CONTENT_TYPE (without the HTTP_ prefix), and Content-Length is CONTENT_LENGTH. This is for historical compatibility.

Validation: Does Your App Comply?

Rack::Lint is a middleware that validates Rack compliance. Wrap your app with it during development:

# config.ru (development)
require 'rack'

app = lambda do |env|
  [200, {'Content-Type' => 'text/plain'}, ['Hello']]
end

# Lint will raise on any spec violation
use Rack::Lint if ENV['RACK_ENV'] == 'development'
run app

Rack::Lint will raise an exception if:

  • Your app doesn't return a three-element array
  • The status isn't an integer
  • Headers aren't a hash of strings
  • The body doesn't respond to each
  • The body elements aren't strings
  • The env is missing required keys

It's useful when writing new middleware or apps. You won't see many Rack violations in production code because frameworks handle this — but when writing bare Rack code, Rack::Lint is your first line of defense.

The Spec Is Deliberately Minimal

The Rack spec doesn't say anything about:

  • How to parse query strings
  • How to parse cookies
  • How to handle sessions
  • How to do routing
  • How to render templates
  • How to parse JSON or form bodies

These are all optional. You can build them yourself, use Rack's helpers, or use a framework. The spec only defines the handshake between server and application, not what the application does with the request.

This minimalism is intentional and correct. It means any Ruby object that can accept a hash and return a three-element array is a web application. It means a Rails app and a Sinatra app and a Roda app and a hand-rolled lambda all speak the same language at the boundary between server and application.

The result is an ecosystem where you can mix and match: Rails routes can mount Sinatra apps, Sinatra apps can mount Rack apps, everything can be wrapped in arbitrary middleware, and the server doesn't care what you're running as long as you respond to call.

The Napkin Version

If you had to write the Rack spec on a napkin, it would say:

call(env) -> [status, headers, body]

env:    Hash of CGI-style variables + rack.* keys
status: Integer HTTP status code
headers: Hash of {String => String}
body:   Responds to each, yields strings

Everything else — sessions, routing, templates, auth — is above this abstraction. The abstraction itself is simple enough to hold in your head, which means you can reason about it clearly when things go wrong.

Next: let's use it.

Your First Rack App (No Training Wheels)

Let's build a Rack application that actually does something. Not a "hello world" one-liner, but a proper small application with routing, multiple responses, and request handling. We'll do it without a framework, using only the rack gem and Ruby's standard library.

Setup

mkdir rack-from-scratch
cd rack-from-scratch
bundle init

Add to your Gemfile:

gem 'rack'
bundle install

The Application

We're going to build a small API that manages a list of notes. In-memory storage, no database, no ORM. Just a hash and some Rack.

# app.rb
require 'json'

class NotesApp
  def initialize
    @notes = {}
    @next_id = 1
  end

  def call(env)
    method = env['REQUEST_METHOD']
    path   = env['PATH_INFO']

    case [method, path]
    when ['GET', '/notes']
      list_notes
    when ['POST', '/notes']
      create_note(env)
    else
      # Match /notes/123
      if (match = path.match(%r{\A/notes/(\d+)\z}))
        id = match[1].to_i
        case method
        when 'GET'    then show_note(id)
        when 'DELETE' then delete_note(id)
        else method_not_allowed
        end
      else
        not_found
      end
    end
  end

  private

  def list_notes
    json_response(200, @notes.values)
  end

  def show_note(id)
    note = @notes[id]
    return not_found unless note
    json_response(200, note)
  end

  def create_note(env)
    body = env['rack.input'].read
    data = JSON.parse(body)

    note = {
      'id'      => @next_id,
      'content' => data['content'].to_s,
      'created' => Time.now.iso8601,
    }

    @notes[@next_id] = note
    @next_id += 1

    json_response(201, note)
  rescue JSON::ParserError
    json_response(400, {'error' => 'Invalid JSON'})
  end

  def delete_note(id)
    return not_found unless @notes.key?(id)
    @notes.delete(id)
    [204, {}, []]
  end

  def not_found
    json_response(404, {'error' => 'Not found'})
  end

  def method_not_allowed
    json_response(405, {'error' => 'Method not allowed'})
  end

  def json_response(status, data)
    body = JSON.generate(data)
    [
      status,
      {
        'Content-Type'   => 'application/json',
        'Content-Length' => body.bytesize.to_s,
      },
      [body]
    ]
  end
end
# config.ru
require_relative 'app'

run NotesApp.new

Start it:

$ bundle exec rackup
Puma starting in single mode...
* Puma version: 6.x
* Min threads: 0, max threads: 5
* Listening on http://127.0.0.1:9292

Using It

# Create a note
$ curl -s -X POST http://localhost:9292/notes \
  -H 'Content-Type: application/json' \
  -d '{"content": "Rack is just a contract"}' | jq .
{
  "id": 1,
  "content": "Rack is just a contract",
  "created": "2026-02-19T12:00:00+00:00"
}

# Create another
$ curl -s -X POST http://localhost:9292/notes \
  -H 'Content-Type: application/json' \
  -d '{"content": "env is just a hash"}' | jq .
{
  "id": 2,
  "content": "env is just a hash",
  "created": "2026-02-19T12:00:01+00:00"
}

# List all notes
$ curl -s http://localhost:9292/notes | jq .
[
  {"id": 1, "content": "Rack is just a contract", "created": "..."},
  {"id": 2, "content": "env is just a hash", "created": "..."}
]

# Get one note
$ curl -s http://localhost:9292/notes/1 | jq .
{"id": 1, "content": "Rack is just a contract", "created": "..."}

# Delete a note
$ curl -s -X DELETE http://localhost:9292/notes/1
# 204 No Content, empty body

# Confirm deletion
$ curl -s http://localhost:9292/notes/1 | jq .
{"error": "Not found"}

# Invalid JSON
$ curl -s -X POST http://localhost:9292/notes \
  -H 'Content-Type: application/json' \
  -d 'not json' | jq .
{"error": "Invalid JSON"}

This is a functional REST API. No framework. No router gem. About 80 lines of Ruby.

What We're Missing

This is a good moment to notice what we haven't done:

No request parsing helpers. We read env['rack.input'].read directly and parsed JSON ourselves. For URL-encoded form data, we'd need to parse name=value&other=thing ourselves, or reach for Rack::Utils.parse_query.

No URL helpers. We matched routes with a case statement and regex. This works but doesn't scale gracefully.

No content negotiation. We ignore the client's Accept header. A real API should check whether the client wants JSON before sending JSON.

No error handling for the whole app. If something explodes with an unexpected exception, Rack's handler returns a 500 with a generic page. We'd want to catch and format that ourselves.

No middleware. No logging, no session handling, no CORS headers.

These aren't criticisms — they're deliberate omissions to keep the example clear. For production, you'd add them, or use a framework that provides them as defaults.

Adding Rack's Own Helpers

The rack gem includes utilities you can use without a framework. Let's use a couple:

require 'json'
require 'rack'

class NotesApp
  def call(env)
    request = Rack::Request.new(env)

    method = request.request_method
    path   = request.path_info

    # Parse query parameters automatically
    page = request.params['page']&.to_i || 1

    # Check content type on POST
    if request.post? && !request.content_type&.include?('application/json')
      return json_response(415, {'error' => 'Content-Type must be application/json'})
    end

    # ... rest of routing ...
  end
end

Rack::Request wraps the env hash and provides methods like:

  • request.get?, request.post?, request.delete?
  • request.path_info — same as env['PATH_INFO']
  • request.params — merged GET and POST params, URL-decoded
  • request.body.read — the request body
  • request.content_type
  • request.cookies — parsed cookie hash
  • request.xhr? — true if it's an XMLHttpRequest
  • request.ip — client IP address

And Rack::Response for building responses:

def json_response(status, data)
  body = JSON.generate(data)
  response = Rack::Response.new
  response.status = status
  response['Content-Type'] = 'application/json'
  response.write(body)
  response.finish  # returns [status, headers, body]
end

These are thin wrappers around the same env hash and response array. They don't add framework overhead — they add ergonomics.

Writing a Test

Because this is plain Ruby, testing is straightforward. You don't need a test server. You just call call with a fake env:

# test_app.rb
require 'minitest/autorun'
require 'json'
require 'rack/mock'
require_relative 'app'

class TestNotesApp < Minitest::Test
  def setup
    @app = NotesApp.new
  end

  def test_empty_list
    status, headers, body = get('/notes')
    assert_equal 200, status
    assert_equal 'application/json', headers['Content-Type']
    assert_equal [], JSON.parse(body.join)
  end

  def test_create_note
    status, headers, body = post('/notes', '{"content": "test note"}')
    assert_equal 201, status
    data = JSON.parse(body.join)
    assert_equal 'test note', data['content']
    assert data['id']
  end

  def test_show_note
    _, _, body = post('/notes', '{"content": "hello"}')
    id = JSON.parse(body.join)['id']

    status, _, body = get("/notes/#{id}")
    assert_equal 200, status
    assert_equal 'hello', JSON.parse(body.join)['content']
  end

  def test_not_found
    status, _, body = get('/notes/999')
    assert_equal 404, status
    assert_equal 'Not found', JSON.parse(body.join)['error']
  end

  def test_delete_note
    _, _, body = post('/notes', '{"content": "delete me"}')
    id = JSON.parse(body.join)['id']

    status, _, _ = delete("/notes/#{id}")
    assert_equal 204, status

    status, _, _ = get("/notes/#{id}")
    assert_equal 404, status
  end

  private

  def get(path)
    env = Rack::MockRequest.env_for(path, method: 'GET')
    @app.call(env)
  end

  def post(path, body)
    env = Rack::MockRequest.env_for(path,
      method: 'POST',
      input: body,
      'CONTENT_TYPE' => 'application/json'
    )
    @app.call(env)
  end

  def delete(path)
    env = Rack::MockRequest.env_for(path, method: 'DELETE')
    @app.call(env)
  end
end

Rack::MockRequest.env_for builds a valid Rack env hash for testing purposes. Run it:

$ ruby test_app.rb
Run options: --seed 12345

# Running:

.....

Finished in 0.001s, 4000.0 runs/s.
5 runs, 8 assertions, 0 failures, 0 errors, 0 skips

Five tests, sub-millisecond runtime, no HTTP server, no magic. The application is a Ruby object. You test it like one.

The Insight

Here's the moment this chapter promised:

A web application is a function. It takes input (the env hash) and returns output (status, headers, body). Testing it is exactly as easy as testing any other function. The fact that it handles HTTP is incidental to what it actually is: an object with a call method.

This is why Rack applications are easy to compose, easy to test, and easy to reason about. The framework complexity you're accustomed to isn't inherent to web development — it's a response to problems that arise at scale. At small scale, or with the right tools, you don't always need it.

Next: let's build the thing that calls your app — the server itself.

Build a Rack Server from Scratch

A Rack server has one job: accept HTTP connections, parse them into a Rack env hash, call your application, and serialize the response back into HTTP. Let's build one.

This isn't a production server. It handles one request at a time, ignores keep-alive, has no TLS, and will fall over under load. It is, however, a real HTTP server that speaks valid HTTP/1.1 and can run actual Rack applications. Understanding it will demystify everything that happens before your application code runs.

The Structure

A Rack server needs to:

  1. Listen on a TCP port
  2. Accept connections in a loop
  3. Parse the HTTP request into a Rack env hash
  4. Call the application with the env
  5. Serialize the [status, headers, body] response into HTTP
  6. Write it to the socket

Let's build each piece.

Step 1: The TCP Listener

require 'socket'

server = TCPServer.new('0.0.0.0', 9292)
puts "Listening on http://localhost:9292"

loop do
  client = server.accept
  # handle client
  client.close
end

TCPServer.new opens a socket. server.accept blocks until a connection arrives, then returns a TCPSocket representing that connection. Straightforward.

Step 2: Parsing the HTTP Request

HTTP requests look like this:

GET /path?query=string HTTP/1.1\r\n
Host: localhost:9292\r\n
Accept: text/html\r\n
\r\n

We need to parse this into a Rack env hash. The tricky parts are:

  • Headers end at a blank line (\r\n alone)
  • The body follows the blank line, if Content-Length is set
  • Header names become HTTP_UPPERCASED_WITH_UNDERSCORES
def parse_request(client)
  # Read the request line
  request_line = client.gets&.chomp
  return nil unless request_line

  method, full_path, http_version = request_line.split(' ', 3)
  path, query_string = full_path.split('?', 2)

  # Read headers until blank line
  headers = {}
  while (line = client.gets&.chomp) && !line.empty?
    name, value = line.split(': ', 2)
    headers[name] = value
  end

  # Read body if Content-Length is present
  body = ''
  if (length = headers['Content-Length']&.to_i) && length > 0
    body = client.read(length)
  end

  # Build the Rack env
  env = {
    # Required CGI variables
    'REQUEST_METHOD'    => method,
    'SCRIPT_NAME'       => '',
    'PATH_INFO'         => path,
    'QUERY_STRING'      => query_string || '',
    'SERVER_NAME'       => 'localhost',
    'SERVER_PORT'       => '9292',
    'HTTP_VERSION'      => http_version,
    'SERVER_PROTOCOL'   => http_version,

    # Rack-specific
    'rack.version'      => [1, 3],
    'rack.input'        => StringIO.new(body),
    'rack.errors'       => $stderr,
    'rack.multithread'  => false,
    'rack.multiprocess' => false,
    'rack.run_once'     => false,
    'rack.url_scheme'   => 'http',
  }

  # Convert HTTP headers to CGI format
  headers.each do |name, value|
    # Content-Type and Content-Length get special treatment
    key = case name
          when 'Content-Type'   then 'CONTENT_TYPE'
          when 'Content-Length' then 'CONTENT_LENGTH'
          else "HTTP_#{name.upcase.gsub('-', '_')}"
          end
    env[key] = value
  end

  env
end

The header name transformation — Content-Type becomes HTTP_CONTENT_TYPE, X-Request-Id becomes HTTP_X_REQUEST_ID — is a CGI convention that Rack inherits. It's annoying but consistent.

Step 3: Serializing the Response

The response is [status, headers, body]. We need to turn that into HTTP/1.1 text:

STATUS_PHRASES = {
  200 => 'OK',
  201 => 'Created',
  204 => 'No Content',
  301 => 'Moved Permanently',
  302 => 'Found',
  304 => 'Not Modified',
  400 => 'Bad Request',
  401 => 'Unauthorized',
  403 => 'Forbidden',
  404 => 'Not Found',
  405 => 'Method Not Allowed',
  415 => 'Unsupported Media Type',
  422 => 'Unprocessable Entity',
  500 => 'Internal Server Error',
}.freeze

def send_response(client, status, headers, body)
  phrase = STATUS_PHRASES[status] || 'Unknown'

  # Status line
  client.write("HTTP/1.1 #{status} #{phrase}\r\n")

  # Headers
  headers.each do |name, value|
    client.write("#{name}: #{value}\r\n")
  end

  # Blank line separating headers from body
  client.write("\r\n")

  # Body — iterate over whatever the app gave us
  body.each do |chunk|
    client.write(chunk)
  end

  # Some body objects need to be closed (file handles, etc.)
  body.close if body.respond_to?(:close)
end

Putting It Together

# tiny_server.rb
require 'socket'
require 'stringio'

STATUS_PHRASES = {
  200 => 'OK', 201 => 'Created', 204 => 'No Content',
  301 => 'Moved Permanently', 302 => 'Found',
  400 => 'Bad Request', 401 => 'Unauthorized',
  403 => 'Forbidden', 404 => 'Not Found',
  405 => 'Method Not Allowed', 500 => 'Internal Server Error',
}.freeze

def parse_request(client)
  request_line = client.gets&.chomp
  return nil unless request_line && !request_line.empty?

  method, full_path, http_version = request_line.split(' ', 3)
  path, query_string = full_path.split('?', 2)

  headers = {}
  while (line = client.gets&.chomp) && !line.empty?
    name, value = line.split(': ', 2)
    headers[name] = value
  end

  body = ''
  if (length = headers['Content-Length']&.to_i) && length > 0
    body = client.read(length)
  end

  env = {
    'REQUEST_METHOD'    => method,
    'SCRIPT_NAME'       => '',
    'PATH_INFO'         => path,
    'QUERY_STRING'      => query_string || '',
    'SERVER_NAME'       => 'localhost',
    'SERVER_PORT'       => '9292',
    'SERVER_PROTOCOL'   => http_version || 'HTTP/1.1',
    'rack.version'      => [1, 3],
    'rack.input'        => StringIO.new(body),
    'rack.errors'       => $stderr,
    'rack.multithread'  => false,
    'rack.multiprocess' => false,
    'rack.run_once'     => false,
    'rack.url_scheme'   => 'http',
  }

  headers.each do |name, value|
    key = case name
          when 'Content-Type'   then 'CONTENT_TYPE'
          when 'Content-Length' then 'CONTENT_LENGTH'
          else "HTTP_#{name.upcase.tr('-', '_')}"
          end
    env[key] = value
  end

  env
end

def send_response(client, status, headers, body)
  phrase = STATUS_PHRASES[status] || 'Unknown'
  client.write("HTTP/1.1 #{status} #{phrase}\r\n")
  headers.each { |name, value| client.write("#{name}: #{value}\r\n") }
  client.write("\r\n")
  body.each { |chunk| client.write(chunk) }
  body.close if body.respond_to?(:close)
end

def run(app, port: 9292)
  server = TCPServer.new('0.0.0.0', port)
  puts "TinyServer listening on http://localhost:#{port}"

  loop do
    client = server.accept

    begin
      env = parse_request(client)

      if env
        status, headers, body = app.call(env)
        send_response(client, status, headers, body)
      end
    rescue => e
      $stderr.puts "Error handling request: #{e.message}"
      $stderr.puts e.backtrace.first(5).join("\n")

      error_body = "Internal Server Error\n"
      client.write("HTTP/1.1 500 Internal Server Error\r\n")
      client.write("Content-Type: text/plain\r\n")
      client.write("Content-Length: #{error_body.bytesize}\r\n")
      client.write("\r\n")
      client.write(error_body)
    ensure
      client.close
    end
  end
end

Running It with a Real App

Let's plug in the notes app from the previous chapter:

# run_notes.rb
require_relative 'tiny_server'
require_relative 'app'  # the NotesApp from the previous chapter

run NotesApp.new, port: 9292
$ ruby run_notes.rb
TinyServer listening on http://localhost:9292
$ curl -s http://localhost:9292/notes
[]

$ curl -s -X POST http://localhost:9292/notes \
  -H 'Content-Type: application/json' \
  -d '{"content": "It works"}' | jq .
{"id":1,"content":"It works","created":"2026-02-19T12:00:00+00:00"}

$ curl -s http://localhost:9292/notes | jq .
[{"id":1,"content":"It works","created":"2026-02-19T12:00:00+00:00"}]

Your handwritten server, running your handwritten app. Real HTTP, real TCP sockets.

Making It Threaded

The current server handles one request at a time — the next server.accept doesn't run until the current request is finished. For a learning tool, fine. For anything resembling concurrent use, we need threads:

def run(app, port: 9292)
  server = TCPServer.new('0.0.0.0', port)
  puts "TinyServer (threaded) on http://localhost:#{port}"

  loop do
    client = server.accept

    Thread.new(client) do |conn|
      begin
        env = parse_request(conn)
        if env
          status, headers, body = app.call(env)
          send_response(conn, status, headers, body)
        end
      rescue => e
        $stderr.puts "Error: #{e.message}"
      ensure
        conn.close
      end
    end
  end
end

Each connection gets its own thread. The main loop immediately returns to accept, ready for the next connection. This is essentially what WEBrick does (minus SSL, keep-alive, virtual host support, and a decade of edge-case handling).

What We're Not Handling

A production HTTP/1.1 server needs to handle:

  • Keep-alive connections: HTTP/1.1 keeps connections open by default. Our server closes after every response, which is valid but wasteful.
  • Chunked transfer encoding: When Content-Length is unknown at response time, you can send data in chunks.
  • HTTP pipelining: Multiple requests on the same connection before any response.
  • Request timeouts: A client that connects and never sends data will tie up a thread forever.
  • Very large bodies: We read the entire body into memory. For file uploads, you'd want streaming.
  • SSL/TLS: Everything above is cleartext.
  • HTTP/2: A binary protocol with multiplexing; fundamentally different from HTTP/1.1.

Puma, the default Rails server, handles all of these. It's about 10,000 lines of code. Our server is about 80. The gap is instructive — those 9,920 lines are solving real, hard problems. But the core idea — parse a hash, call an object, serialize the result — is in our 80 lines.

The Moment

Here it is: the only thing a web server does is build a hash and call your code. The hash has a few required keys. Your code returns a three-element array. The server turns that array into text and sends it over a socket.

When Puma says it "runs Rack applications," this is what it means. When we say "Rack-compatible server," we mean "a server that knows how to build this specific hash and interpret this specific array." The protocol is simple enough that we just implemented a conforming server in under a hundred lines.

Next: the middleware chain that sits between the server and your app.

Middleware: Turtles All the Way Down

Middleware is the most overloaded term in web development. In Rack's context, it has a precise meaning: a middleware is a Rack application that wraps another Rack application.

That's it. An object with a call method that receives an inner app in its initializer, delegates to it, and adds some behavior before or after (or instead of) that delegation.

The Pattern

class MyMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    # Do something before the inner app runs
    
    status, headers, body = @app.call(env)
    
    # Do something after the inner app runs
    
    [status, headers, body]
  end
end

This is the complete middleware pattern. Everything else is elaboration.

The initialize method receives the next application in the chain. The call method can:

  • Inspect or modify env before passing it down
  • Decide not to call @app at all (short-circuit)
  • Inspect or modify the [status, headers, body] before returning it up
  • Call @app multiple times (for retry logic)
  • Do work in a separate thread (for async logging)

A Real Example: Request Logging

class RequestLogger
  def initialize(app, logger: $stdout)
    @app    = app
    @logger = logger
  end

  def call(env)
    start  = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    method = env['REQUEST_METHOD']
    path   = env['PATH_INFO']
    
    status, headers, body = @app.call(env)
    
    elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
    ms = (elapsed * 1000).round(1)

    @logger.puts "[#{Time.now.strftime('%H:%M:%S')}] #{method} #{path} → #{status} (#{ms}ms)"

    [status, headers, body]
  end
end

Use it:

# config.ru
require_relative 'app'
require_relative 'request_logger'

use RequestLogger
run NotesApp.new

Now every request is logged:

[12:00:00] GET /notes → 200 (0.3ms)
[12:00:01] POST /notes → 201 (0.8ms)
[12:00:02] GET /notes/1 → 200 (0.2ms)
[12:00:03] DELETE /notes/1 → 204 (0.1ms)
[12:00:04] GET /notes/999 → 404 (0.1ms)

The application knows nothing about this. NotesApp didn't change. The logging behavior is composed around it.

A Real Example: Authentication

class BasicAuth
  def initialize(app, realm:, credentials:)
    @app         = app
    @realm       = realm
    @credentials = credentials  # { username => password }
  end

  def call(env)
    auth = env['HTTP_AUTHORIZATION']

    if auth && auth.start_with?('Basic ')
      encoded = auth.sub('Basic ', '')
      username, password = Base64.decode64(encoded).split(':', 2)

      if @credentials[username] == password
        # Auth success — pass through to the app
        env['authenticated_user'] = username
        return @app.call(env)
      end
    end

    # Auth failed — short-circuit, don't call the inner app
    [
      401,
      {
        'Content-Type'     => 'text/plain',
        'WWW-Authenticate' => "Basic realm=\"#{@realm}\"",
      },
      ['Unauthorized']
    ]
  end
end

Use it:

# config.ru
require 'base64'
require_relative 'app'
require_relative 'basic_auth'

use BasicAuth,
  realm: 'Notes API',
  credentials: { 'admin' => 'secret' }

run NotesApp.new

Now:

# No credentials
$ curl -s http://localhost:9292/notes
Unauthorized

# Wrong password
$ curl -s -u admin:wrong http://localhost:9292/notes
Unauthorized

# Correct credentials
$ curl -s -u admin:secret http://localhost:9292/notes
[]

The inner app still knows nothing about authentication. NotesApp didn't change. Authentication is entirely handled in the middleware layer.

A Real Example: Response Time Header

class ResponseTime
  HEADER = 'X-Response-Time'.freeze

  def initialize(app)
    @app = app
  end

  def call(env)
    start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    status, headers, body = @app.call(env)
    elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start

    headers[HEADER] = "#{(elapsed * 1000).round(2)}ms"

    [status, headers, body]
  end
end

This modifies the response rather than the request. It adds a header after the inner app runs.

Composing Multiple Middlewares

When you use multiple middlewares in config.ru, they nest:

# config.ru
use ResponseTime
use RequestLogger
use BasicAuth, realm: 'Notes', credentials: { 'user' => 'pass' }
run NotesApp.new

The call stack for a request is:

ResponseTime.call(env)
  RequestLogger.call(env)
    BasicAuth.call(env)
      NotesApp.call(env)    # only if auth passes
    BasicAuth returns [status, headers, body]
  RequestLogger returns [status, headers, body] (after logging)
ResponseTime returns [status, headers, body] (after adding X-Response-Time)

The first use is the outermost layer. The run is the innermost. Middleware added first wraps everything else.

This has a non-obvious implication: RequestLogger runs inside ResponseTime. If RequestLogger adds 0.1ms of overhead, that overhead is included in the response time that ResponseTime measures. Whether that's what you want depends on what you're measuring.

Building a Middleware Stack Manually

Rack::Builder (what config.ru uses) is just a class that builds a chain of middlewares. We can do it manually to see the structure:

require_relative 'app'
require_relative 'request_logger'
require_relative 'response_time'

# Build the stack by hand — innermost to outermost
inner  = NotesApp.new
logged = RequestLogger.new(inner)
timed  = ResponseTime.new(logged)

# timed is the outermost app — this is what the server calls
status, headers, body = timed.call(env)

Or using Rack::Builder directly:

app = Rack::Builder.new do
  use ResponseTime
  use RequestLogger
  run NotesApp.new
end

# app.call(env) now goes through the whole stack

Rack::Builder does exactly what we did manually — it builds a chain of closures, each wrapping the next.

Middleware from the Rack Gem

The rack gem ships with a set of useful middlewares:

# Adds X-Runtime header (response time)
use Rack::Runtime

# Rewrites POST bodies with _method=DELETE to DELETE requests
use Rack::MethodOverride

# Adds ETag and Last-Modified for conditional GET support
use Rack::ConditionalGet
use Rack::ETag

# Compresses responses with gzip when client supports it
use Rack::Deflater

# Serves static files from ./public
use Rack::Static, urls: ['/assets'], root: 'public'

# Basic request/response logging
use Rack::CommonLogger

# Cookie-based sessions
use Rack::Session::Cookie, secret: 'your_secret_key'

These are all just implementations of the same pattern: initialize with app, implement call.

Middleware Order Matters

# WRONG order: Static serves before auth check
use Rack::Static, urls: ['/admin/files']
use BasicAuth, realm: 'Admin'
run AdminApp.new

# RIGHT order: Auth runs first, wraps everything including Static
use BasicAuth, realm: 'Admin'
use Rack::Static, urls: ['/admin/files']
run AdminApp.new

In the wrong order, requests to /admin/files/secret.pdf bypass authentication because Rack::Static intercepts them before BasicAuth gets a chance to check credentials.

This kind of bug is especially fun to debug when you inherited the codebase.

Conditional Middleware

Sometimes you want middleware only in certain environments:

# config.ru
if ENV['RACK_ENV'] == 'development'
  use Rack::Lint       # validates Rack compliance — catches your bugs
  use RequestLogger
end

use Rack::Session::Cookie, secret: ENV.fetch('SESSION_SECRET')
run MyApp.new

Rack::Lint is particularly useful during development — it validates that your app and middleware are conforming to the spec and raises helpful errors when they don't.

Writing Middleware That Passes Options

A common pattern is passing configuration at use-time:

class RateLimiter
  def initialize(app, limit: 100, window: 60)
    @app    = app
    @limit  = limit
    @window = window
    @counts = Hash.new(0)
    @mutex  = Mutex.new
  end

  def call(env)
    ip = env['REMOTE_ADDR']

    @mutex.synchronize do
      @counts[ip] += 1

      if @counts[ip] > @limit
        return [
          429,
          {'Content-Type' => 'text/plain', 'Retry-After' => @window.to_s},
          ['Too Many Requests']
        ]
      end
    end

    @app.call(env)
  end
end

# In config.ru:
use RateLimiter, limit: 50, window: 30

The initialize arguments after app are the middleware's configuration options. Rack::Builder passes them through when you write use RateLimiter, limit: 50.

The Insight

Here's what took me too long to realize: middleware is just objects calling other objects. There's no framework magic. There's no DSL. There's no reflection or code generation. It's a chain of Ruby objects where each one holds a reference to the next and delegates to it.

When you understand this, you can:

  • Read any middleware and understand it immediately
  • Write middleware that does exactly what you need
  • Debug middleware issues by temporarily removing layers
  • Understand why middleware order matters

The entire Rack ecosystem — gems, frameworks, servers — is built on this pattern. A Rails app with 20 middlewares is just 20 objects arranged in a chain. When something goes wrong in that chain, you now know how to find it.

Next: what those middlewares are usually protecting — your routing.

Routing Without a Framework (It's Just String Matching)

Routing is the process of mapping an incoming HTTP request to a handler. Frameworks make this look sophisticated. It isn't. It's string matching with some parameter extraction.

Let's implement a router from scratch, then look at what frameworks add on top.

What Routing Actually Is

Given a request with METHOD = "GET" and PATH_INFO = "/users/42/posts", routing finds the code that should handle it. The two inputs are the HTTP method and the path. The outputs are: either "run this code" or "404."

That's the whole problem. Everything else is ergonomics.

The Simplest Router

From the previous chapter's NotesApp, we had:

case [method, path]
when ['GET', '/notes']
  list_notes
when ['POST', '/notes']
  create_note(env)
end

This is a router. It matches on method and exact path. The problem: it doesn't handle path parameters (/notes/42).

Path Parameters via Regex

class Router
  def initialize
    @routes = []
  end

  def add(method, pattern, &handler)
    # Convert /users/:id/posts to a regex with named captures
    regex = pattern.gsub(/:(\w+)/, '(?<\1>[^/]+)')
    regex = Regexp.new("\\A#{regex}\\z")
    @routes << {method: method, pattern: regex, handler: handler}
  end

  def get(path, &block)  = add('GET',    path, &block)
  def post(path, &block) = add('POST',   path, &block)
  def put(path, &block)  = add('PUT',    path, &block)
  def patch(path, &block)= add('PATCH',  path, &block)
  def delete(path, &block)=add('DELETE', path, &block)

  def call(env)
    method = env['REQUEST_METHOD']
    path   = env['PATH_INFO']

    @routes.each do |route|
      next unless route[:method] == method
      next unless (match = route[:pattern].match(path))

      # Extract named captures as string keys
      params = match.named_captures
      env['router.params'] = params

      return route[:handler].call(env, params)
    end

    [404, {'Content-Type' => 'text/plain'}, ['Not Found']]
  end
end

Using it:

router = Router.new

router.get('/') do |env, params|
  [200, {'Content-Type' => 'text/plain'}, ['Welcome']]
end

router.get('/users/:id') do |env, params|
  [200, {'Content-Type' => 'text/plain'}, ["User #{params['id']}"]]
end

router.get('/users/:user_id/posts/:id') do |env, params|
  body = "Post #{params['id']} by user #{params['user_id']}"
  [200, {'Content-Type' => 'text/plain'}, [body]]
end

router.post('/users') do |env, params|
  # Create a user...
  [201, {'Content-Type' => 'text/plain'}, ['Created']]
end

Test it:

require 'rack/mock'

def request(router, method, path)
  env = Rack::MockRequest.env_for(path, method: method)
  status, _, body = router.call(env)
  [status, body.join]
end

puts request(router, 'GET',  '/')                          # [200, "Welcome"]
puts request(router, 'GET',  '/users/42')                  # [200, "User 42"]
puts request(router, 'GET',  '/users/42/posts/7')          # [200, "Post 7 by user 42"]
puts request(router, 'POST', '/users')                     # [201, "Created"]
puts request(router, 'GET',  '/nonexistent')               # [404, "Not Found"]

The Regex Trick

The pattern translation deserves a closer look:

pattern = '/users/:id/posts/:post_id'

# Step 1: Replace :param with a named capture group
regex_str = pattern.gsub(/:(\w+)/, '(?<\1>[^/]+)')
# => "/users/(?<id>[^/]+)/posts/(?<post_id>[^/]+)"

# Step 2: Anchor it
regex = Regexp.new("\\A#{regex_str}\\z")
# => /\A\/users\/(?<id>[^\/]+)\/posts\/(?<post_id>[^\/]+)\z/

# Test it
match = regex.match('/users/42/posts/7')
match.named_captures   # => {"id"=>"42", "post_id"=>"7"}

[^/]+ matches one or more characters that aren't a slash — which is what a URL segment is. Named captures (the ?<name> syntax) let us extract those values by name.

This is what every Ruby routing library does underneath. Some add wildcard matching (*path), optional segments ((/edit)?), or format matching (.json). The core is always the same regex transform.

Constraint-Based Routing

Rails routes support constraints like id: /\d+/. We can add that:

def add(method, pattern, constraints: {}, &handler)
  # Build base regex, replacing :param with a named capture
  regex_str = pattern.gsub(/:(\w+)/) do |match|
    param_name = $1
    # Use constraint regex if provided, otherwise match any non-slash chars
    param_pattern = constraints[param_name.to_sym]&.source || '[^/]+'
    "(?<#{param_name}>#{param_pattern})"
  end

  regex = Regexp.new("\\A#{regex_str}\\z")
  @routes << {method: method, pattern: regex, handler: handler}
end

# Usage: only match numeric IDs
router.get('/users/:id', constraints: {id: /\d+/}) do |env, params|
  [200, {}, ["User #{params['id']}"]]
end

# This matches:   GET /users/42
# This doesn't:   GET /users/alice

Mounting Rack Apps at Paths

Routing isn't just about handlers — you can route to entire Rack applications:

class PathRouter
  def initialize
    @mounts = []
    @routes = []
  end

  def mount(path, app)
    @mounts << {prefix: path, app: app}
  end

  def call(env)
    path = env['PATH_INFO']

    # Check mounts first — rewrite PATH_INFO for the mounted app
    @mounts.each do |mount|
      if path.start_with?(mount[:prefix])
        env = env.merge(
          'SCRIPT_NAME' => env['SCRIPT_NAME'] + mount[:prefix],
          'PATH_INFO'   => path.sub(mount[:prefix], '') || '/',
        )
        return mount[:app].call(env)
      end
    end

    # Then check plain routes
    # ... (same as before)
    
    [404, {'Content-Type' => 'text/plain'}, ['Not Found']]
  end
end

# Example:
router = PathRouter.new
router.mount('/api/v1', ApiApp.new)
router.mount('/admin', AdminApp.new)

This is exactly how Rails's mount directive works. SCRIPT_NAME tracks how much of the path has been consumed by the routing layer, and PATH_INFO contains the remaining path for the mounted app to interpret.

Let's build a complete router that a small real application could actually use:

# router.rb
class Router
  Route = Struct.new(:method, :pattern, :named_params, :handler)

  def initialize
    @routes = []
    @not_found = method(:default_not_found)
    @error     = method(:default_error)
  end

  def get(path, &block)    = define('GET',    path, &block)
  def post(path, &block)   = define('POST',   path, &block)
  def put(path, &block)    = define('PUT',    path, &block)
  def patch(path, &block)  = define('PATCH',  path, &block)
  def delete(path, &block) = define('DELETE', path, &block)
  def head(path, &block)   = define('HEAD',   path, &block)

  def not_found(&block) = (@not_found = block)
  def error(&block)     = (@error     = block)

  def call(env)
    method = env['REQUEST_METHOD']
    path   = env['PATH_INFO'].chomp('/')
    path   = '/' if path.empty?

    @routes.each do |route|
      next unless route.method == method || (method == 'HEAD' && route.method == 'GET')

      if (match = route.pattern.match(path))
        params = match.named_captures
        env['router.params'] = params
        return route.handler.call(env, params)
      end
    end

    @not_found.call(env)
  rescue StandardError => e
    @error.call(env, e)
  end

  private

  def define(method, path, &handler)
    named_params = path.scan(/:(\w+)/).flatten
    pattern_str  = path.gsub(/:(\w+)/, '(?<\1>[^/]+)')
    pattern      = Regexp.new("\\A#{pattern_str}\\z")
    @routes << Route.new(method, pattern, named_params, handler)
  end

  def default_not_found(env)
    [404, {'Content-Type' => 'text/plain'}, ['Not Found']]
  end

  def default_error(env, exception)
    $stderr.puts "#{exception.class}: #{exception.message}"
    $stderr.puts exception.backtrace.first(10).join("\n")
    [500, {'Content-Type' => 'text/plain'}, ['Internal Server Error']]
  end
end

Use it as a Rack app:

# config.ru
require_relative 'router'
require_relative 'handlers'  # wherever your handler code lives

router = Router.new

router.get('/') do |env, params|
  [200, {'Content-Type' => 'text/html'}, ['<h1>Home</h1>']]
end

router.get('/users/:id') do |env, params|
  user = User.find(params['id'].to_i)
  [200, {'Content-Type' => 'application/json'}, [user.to_json]]
end

router.not_found do |env|
  [404, {'Content-Type' => 'text/html'}, ['<h1>Page Not Found</h1>']]
end

run router

What Frameworks Add on Top

Our router covers the basics. Here's what Rails's and Sinatra's routers add:

Named routes and URL helpers: user_path(id: 42) instead of "/users/#{42}". This requires storing route patterns as templates, not just regexes.

Nested resources: resources :users do; resources :posts; end generates all CRUD routes for posts nested under users. Our router requires you to define each route manually.

Route priorities and overrides: When multiple routes could match, Rails has a precise priority order. Our router uses first-match-wins, which is simpler but less flexible.

Format matching: Rails can route GET /users/42.json differently from GET /users/42, based on the format suffix or Accept header.

Redirect and inline responders: get '/old', redirect('/new') in Sinatra.

Route constraints with arbitrary code: Rails lets you pass lambdas as constraints.

These are real features that real applications use. They're also each independently implementable on top of what we've built — the router isn't magical, it's just accreted features.

The Insight

A router is a list of (method, pattern, handler) tuples. Matching is: iterate the list, test each pattern against the incoming path, run the first match. Everything else is optimization or ergonomics.

When you see a routing DSL — resources :users, namespace :api, scope '/v2' — it's generating these tuples. The DSL exists because writing tuples manually is tedious at scale. But it's still tuples.

If you ever need to debug a routing issue, you can inspect the generated routes. In Rails: Rails.application.routes.routes gives you the raw route list. In Sinatra: MyApp.routes shows all defined routes. You're looking at the tuples.

Next: making request and response handling a little less manual.

Request and Response Objects (DIY Edition)

By now you've been reading env['REQUEST_METHOD'] and returning [200, headers, body] directly. This works, but it's verbose. Real applications build thin wrapper objects around the raw data structures to make the common cases easier.

The rack gem ships with Rack::Request and Rack::Response. They're good. But let's build our own versions first, so we understand what they're doing and why.

The Problem with Raw Env

Accessing request data from the raw env hash has a few annoyances:

# Reading headers is inconsistent
host         = env['HTTP_HOST']            # most headers
content_type = env['CONTENT_TYPE']        # except Content-Type
content_len  = env['CONTENT_LENGTH']      # and Content-Length

# Checking HTTP method
is_get  = env['REQUEST_METHOD'] == 'GET'
is_post = env['REQUEST_METHOD'] == 'POST'

# Reading the body (destructive! can only read once)
body = env['rack.input'].read

# Parsing query string
require 'uri'
params = URI.decode_www_form(env['QUERY_STRING']).to_h

# Getting the full URL
scheme = env['rack.url_scheme']
host   = env['HTTP_HOST']
path   = env['PATH_INFO']
qs     = env['QUERY_STRING']
url    = "#{scheme}://#{host}#{path}"
url   += "?#{qs}" unless qs.empty?

None of this is wrong, but it's tedious. A request object wraps these lookups in named methods.

Building a Request Wrapper

class Request
  attr_reader :env

  def initialize(env)
    @env = env
  end

  # HTTP method
  def request_method = env['REQUEST_METHOD']
  def get?    = request_method == 'GET'
  def post?   = request_method == 'POST'
  def put?    = request_method == 'PUT'
  def patch?  = request_method == 'PATCH'
  def delete? = request_method == 'DELETE'
  def head?   = request_method == 'HEAD'

  # Path and query
  def path         = env['PATH_INFO']
  def query_string = env['QUERY_STRING']
  def script_name  = env['SCRIPT_NAME']
  def full_path    = query_string.empty? ? path : "#{path}?#{query_string}"

  # URL construction
  def scheme = env['rack.url_scheme'] || 'http'
  def host   = env['HTTP_HOST'] || env['SERVER_NAME']
  def port   = env['SERVER_PORT']
  def url    = "#{scheme}://#{host}#{full_path}"

  # Headers (Rack-normalized: HTTP_ACCEPT -> Accept)
  def headers
    @headers ||= env.each_with_object({}) do |(key, value), h|
      if key.start_with?('HTTP_')
        name = key.sub('HTTP_', '').split('_').map(&:capitalize).join('-')
        h[name] = value
      elsif key == 'CONTENT_TYPE'
        h['Content-Type'] = value
      elsif key == 'CONTENT_LENGTH'
        h['Content-Length'] = value
      end
    end
  end

  def content_type   = env['CONTENT_TYPE']
  def content_length = env['CONTENT_LENGTH']&.to_i

  # Individual header lookup — normalizes to Rack format
  def [](header_name)
    key = "HTTP_#{header_name.upcase.tr('-', '_')}"
    env[key] || env[header_name.upcase.tr('-', '_')]
  end

  # Query parameters (parsed and decoded)
  def query_params
    @query_params ||= parse_query(query_string)
  end

  # POST body params (for application/x-www-form-urlencoded)
  def post_params
    @post_params ||= if content_type&.include?('application/x-www-form-urlencoded')
      parse_query(body_string)
    else
      {}
    end
  end

  # Merged params: query + post body (query takes precedence on collision)
  def params
    @params ||= post_params.merge(query_params)
  end

  # Raw body (reads once, then cached)
  def body
    env['rack.input']
  end

  def body_string
    @body_string ||= begin
      body.rewind  # reset in case it was partially read
      body.read
    end
  end

  # JSON body parsing
  def json
    @json ||= if content_type&.include?('application/json')
      require 'json'
      JSON.parse(body_string)
    end
  end

  # Cookies
  def cookies
    @cookies ||= parse_cookies(env['HTTP_COOKIE'] || '')
  end

  # IP address (respects X-Forwarded-For if behind a proxy)
  def ip
    env['HTTP_X_FORWARDED_FOR']&.split(',')&.first&.strip ||
      env['REMOTE_ADDR']
  end

  # Is this an AJAX request?
  def xhr?
    env['HTTP_X_REQUESTED_WITH']&.downcase == 'xmlhttprequest'
  end

  # What content types does the client accept?
  def accepts?(mime_type)
    accept = env['HTTP_ACCEPT'] || '*/*'
    accept.include?(mime_type) || accept.include?('*/*')
  end

  private

  def parse_query(string)
    return {} if string.nil? || string.empty?
    require 'uri'
    URI.decode_www_form(string).each_with_object({}) do |(k, v), h|
      if h.key?(k)
        h[k] = Array(h[k]) << v
      else
        h[k] = v
      end
    end
  end

  def parse_cookies(string)
    string.split('; ').each_with_object({}) do |pair, h|
      name, value = pair.split('=', 2)
      h[name] = value if name
    end
  end
end

Building a Response Helper

The response is [status, headers, body]. Building it manually is fine for simple cases but gets tedious when you're setting multiple headers or building the body incrementally.

class Response
  attr_accessor :status
  attr_reader :headers

  def initialize(status = 200, headers = {})
    @status  = status
    @headers = {'Content-Type' => 'text/html; charset=utf-8'}.merge(headers)
    @body    = []
    @finished = false
  end

  # Write to the body buffer
  def write(str)
    raise 'Response already finished' if @finished
    @body << str.to_s
    self
  end

  def <<(str) = write(str)

  # Set a header
  def []=(name, value)
    @headers[name] = value
  end

  def [](name)
    @headers[name]
  end

  # Common response types
  def set_cookie(name, value, options = {})
    cookie = "#{name}=#{value}"
    cookie += "; Path=#{options[:path] || '/'}"
    cookie += "; HttpOnly" if options[:http_only] != false
    cookie += "; Secure"   if options[:secure]
    cookie += "; SameSite=#{options[:same_site]}" if options[:same_site]
    if options[:expires]
      cookie += "; Expires=#{options[:expires].httpdate}"
    end
    # Multiple Set-Cookie headers need to be handled carefully
    existing = @headers['Set-Cookie']
    @headers['Set-Cookie'] = existing ? "#{existing}\n#{cookie}" : cookie
  end

  def delete_cookie(name)
    set_cookie(name, '', expires: Time.at(0))
  end

  # Redirect helpers
  def redirect(location, status = 302)
    @status = status
    @headers['Location'] = location
    @body = []
    self
  end

  # Finish: set Content-Length and return the Rack triple
  def finish
    @finished = true
    body = @body

    unless @headers['Content-Length']
      size = body.sum(&:bytesize)
      @headers['Content-Length'] = size.to_s
    end

    [@status, @headers, body]
  end

  # Convenience: finish with a body written all at once
  def self.text(body, status: 200)
    r = new(status, 'Content-Type' => 'text/plain')
    r.write(body)
    r.finish
  end

  def self.html(body, status: 200)
    r = new(status, 'Content-Type' => 'text/html; charset=utf-8')
    r.write(body)
    r.finish
  end

  def self.json(data, status: 200)
    require 'json'
    body = JSON.generate(data)
    r = new(status, 'Content-Type' => 'application/json')
    r.write(body)
    r.finish
  end

  def self.redirect(location, status: 302)
    r = new(status)
    r.redirect(location, status)
    r.finish
  end
end

Using Them Together

Here's our NotesApp rewritten with these helpers:

require_relative 'request'
require_relative 'response'
require 'json'

class NotesApp
  def initialize
    @notes  = {}
    @next_id = 1
  end

  def call(env)
    req = Request.new(env)

    case [req.request_method, req.path]
    when ['GET', '/notes']
      Response.json(@notes.values)

    when ['POST', '/notes']
      create_note(req)

    else
      if (match = req.path.match(%r{\A/notes/(\d+)\z}))
        id = match[1].to_i
        case req.request_method
        when 'GET'    then show_note(id)
        when 'DELETE' then delete_note(id)
        else Response.json({'error' => 'Method not allowed'}, status: 405)
        end
      else
        Response.json({'error' => 'Not found'}, status: 404)
      end
    end
  end

  private

  def show_note(id)
    note = @notes[id]
    return Response.json({'error' => 'Not found'}, status: 404) unless note
    Response.json(note)
  end

  def create_note(req)
    data = req.json
    return Response.json({'error' => 'Invalid JSON'}, status: 400) unless data

    note = {
      'id'      => @next_id,
      'content' => data['content'].to_s,
      'created' => Time.now.iso8601,
    }
    @notes[@next_id] = note
    @next_id += 1

    Response.json(note, status: 201)
  rescue JSON::ParserError
    Response.json({'error' => 'Invalid JSON'}, status: 400)
  end

  def delete_note(id)
    return Response.json({'error' => 'Not found'}, status: 404) unless @notes.key?(id)
    @notes.delete(id)
    [204, {}, []]
  end
end

Cleaner. req.json instead of JSON.parse(env['rack.input'].read). Response.json(data) instead of the three-line array construction.

Rack's Built-in Versions

Rack::Request and Rack::Response do everything above, plus:

  • Rack::Request#params handles multipart form data (file uploads)
  • Rack::Request#session accesses the session (set by session middleware)
  • Rack::Response handles Transfer-Encoding: chunked for streaming
  • Both handle edge cases in encoding and parsing that our implementations skip

There's no reason to use our versions in production. But now you know what they are: thin wrappers that make the env hash and response array more ergonomic. Not magic. Not framework infrastructure. Just Ruby objects.

The Body Is Lazy

One important detail about the response body: it's supposed to be lazy. The Rack spec says the body responds to each, and the server calls each to get the chunks. It doesn't have to be an Array.

This means you can stream large responses without loading everything into memory:

class FileStreamer
  def initialize(path)
    @path = path
  end

  def each
    File.open(@path, 'rb') do |file|
      while (chunk = file.read(16_384))  # read 16KB at a time
        yield chunk
      end
    end
  end

  def close
    # nothing to close — File.open handles it
  end
end

# In a Rack app:
def call(env)
  [
    200,
    {
      'Content-Type'        => 'application/octet-stream',
      'Content-Disposition' => 'attachment; filename="large_file.bin"',
    },
    FileStreamer.new('/path/to/large_file.bin')
  ]
end

The server calls .each on the body, yields chunks to the client as they're read, and memory usage stays flat even for multi-gigabyte files. Rack::Sendfile takes this further — if the server supports it, you tell the server the file path and the server handles the streaming at the OS level, bypassing Ruby entirely.

The Insight

Request and response objects are not architecture — they're ergonomics. The underlying data structures are still a hash and an array. The wrappers exist so you don't have to remember that Content-Type uses a different env key format than other headers, or that the body needs Content-Length set, or that rack.input might need to be rewound before reading.

When Rack::Request or Rack::Response does something unexpected, you can read its source. It's a short file. It's doing exactly what we just did.

This concludes the Rack section. You've seen the spec, built apps against it, written a server, implemented middleware, built a router, and wrapped the env in ergonomic objects. You know what's happening.

Next: why Rack alone still isn't quite enough for most applications.

Why Rack Alone Isn't Enough

We've spent several chapters demonstrating that you can build real web applications with nothing but Rack and Ruby's standard library. This is true. It's also incomplete.

Rack gives you a protocol. What it doesn't give you is a productive way to work at scale. Let's be precise about what's missing.

The Problems That Show Up

Problem 1: Routing Gets Ugly

Our hand-rolled router is workable for small applications. When an application grows to dozens of routes, a flat list of (method, pattern, handler) tuples becomes difficult to maintain.

Consider a typical CRUD application:

# GET  /projects
# POST /projects
# GET  /projects/:id
# PUT  /projects/:id
# DELETE /projects/:id
# GET  /projects/:id/tasks
# POST /projects/:id/tasks
# GET  /projects/:id/tasks/:task_id
# PUT  /projects/:id/tasks/:task_id
# DELETE /projects/:id/tasks/:task_id
# GET  /projects/:id/collaborators
# POST /projects/:id/collaborators
# DELETE /projects/:id/collaborators/:user_id

Thirteen routes. For one resource with two nested sub-resources. A medium-sized application might have ten resources with similar nesting. That's over a hundred routes.

The flat list approach requires you to repeat the :id pattern in every route. It requires you to duplicate the /projects/:id prefix for every nested resource. When you change the prefix, you update dozens of patterns. When you forget one, you get a routing inconsistency.

More specifically: our flat router has no concept of shared route segments. Every route is independent. The information that /projects/:id/tasks and /projects/:id/collaborators both belong to the same project is not represented anywhere.

Problem 2: Before/After Actions Are Awkward

In Rails:

class ProjectsController < ApplicationController
  before_action :authenticate!
  before_action :find_project, only: [:show, :update, :destroy]
  before_action :require_admin, only: [:destroy]
  
  def show
    render json: @project
  end
end

In bare Rack, you'd write this as:

  • A middleware that handles authentication (fine)
  • Code in each route handler that loads the project (repetitive)
  • A conditional check in the delete handler (manual)

You can build before-action systems — they're just arrays of procs called before the handler. But nothing in Rack provides this scaffolding. You write it yourself, every time.

Problem 3: Response Helpers Don't Exist

# In a framework
render json: @user, status: :created

# In bare Rack
body = @user.to_json
[201, {'Content-Type' => 'application/json', 'Content-Length' => body.bytesize.to_s}, [body]]

The framework version is shorter because the framework accumulated helper methods over time. render json: is syntactic sugar over exactly what the Rack version does. The sugar exists because you'd type the verbose version hundreds of times in a real application.

Problem 4: Configuration Seeps Everywhere

A bare Rack app has no natural place to put application-level configuration:

# Where does the database connection go?
# Where does the template engine get configured?
# Where do shared helpers live?
# Where does the application secret key live?

In Rails, the answer to all of these is "the application object and its configuration." In Sinatra, it's set :key, value and settings.key. In bare Rack, you figure it out yourself, and the answer is "some combination of globals, constants, and thread locals."

Problem 5: Template Rendering Is Your Problem

The Rack spec says nothing about templates. If you want ERB or Haml or Mustache, you call the library directly:

require 'erb'

template = ERB.new(File.read('views/users.html.erb'))
html = template.result(binding)  # dangerous: binding exposes entire scope
[200, {'Content-Type' => 'text/html'}, [html]]

This works for one template. For a real application, you need:

  • A way to find template files by name
  • Layout support (a template inside a master template)
  • Partials (including sub-templates)
  • Content type detection
  • Safe access to view helpers without exposing everything via binding

Frameworks provide this. Bare Rack does not.

The Two Solutions

There are two ways to address these problems:

Solution A: Add exactly what you need, nothing more. Write a before-action system that handles your application's specific needs. Use a router library that handles the routing problem but nothing else. Add a template helper that handles the two template engines you actually use. Build up from Rack exactly as far as you need.

This is the Sinatra approach. Sinatra is a small framework that solves the routing and template problems while staying thin everywhere else. It works well for APIs and small web applications.

Solution B: Start with a minimal framework that has correct abstractions, then opt into features as you need them. Rather than building up from primitives, start with a framework that already has the right foundation, and enable features through a plugin system.

This is the Roda approach.

Where Sinatra Falls Short

Sinatra solves the routing problem with a flat DSL:

get '/projects' do
  # ...
end

get '/projects/:id' do
  id = params[:id]
  # ...
end

But it inherits the flat-list problem. As an application grows, Sinatra routes become hard to organize. Sinatra's routing is sequential — the first matching route wins, and the routes are just checked in order. For large applications with complex routing, this can be surprisingly slow, and there's no structural way to represent the hierarchical nature of the URL namespace.

Sinatra also doesn't solve the "shared prefix" problem. If you rename /projects to /p, you update every route that starts with /projects.

What a Better Abstraction Looks Like

The insight that Roda is built on: the URL hierarchy is a tree, and your routing should be a tree too.

Instead of a flat list of routes:

GET  /projects           → list_projects
POST /projects           → create_project
GET  /projects/:id       → show_project
PUT  /projects/:id       → update_project
DELETE /projects/:id     → delete_project

...you write a tree:

/projects
  GET  → list_projects
  POST → create_project
  /:id
    GET    → show_project
    PUT    → update_project
    DELETE → delete_project
    /tasks
      GET  → list_tasks
      POST → create_task

When a request comes in for DELETE /projects/42, you traverse the tree:

  1. Does the path start with /projects? Yes. Consume it.
  2. Is there a :id segment? 42. Consume it.
  3. Nothing remaining. Match on DELETE. Run the handler.

The tree represents the actual structure of your URL namespace. The /projects prefix is stated once. The :id parameter is extracted once and available to all nested routes.

This is what Roda implements. It calls it a "routing tree," and it's the framework's central design decision.

Why This Matters for Performance

Sinatra's routing has O(n) complexity — in the worst case, it checks every route until it finds a match. For an application with 200 routes, a request to the last-defined route or a 404 checks all 200 patterns.

Roda's tree routing has O(log n) complexity roughly — it traverses the tree, discarding entire branches when the path prefix doesn't match. A request for /projects/42 immediately discards everything that doesn't start with /projects, then everything within /projects that doesn't match /42 or /:id. The routing work is proportional to the depth of the match, not the total number of routes.

For most applications, this difference is imperceptible. For high-traffic applications or applications with many routes, it matters.

What Roda Is

Roda is a small Ruby web framework built on Rack with:

  • Tree-based routing
  • A plugin system that lets you add features (sessions, template rendering, JSON helpers, CSRF protection) without pulling in everything at once
  • Correct Rack integration throughout
  • A minimal footprint when you use minimal plugins

It was created by Jeremy Evans (also the author of Sequel, the Ruby database toolkit) and is used in production by applications that process millions of requests per day.

It is not Rails. It doesn't have an ORM, a mailer, an asset pipeline, or conventions for where to put files. It has routing and a plugin system. You add what you need.

Next: let's look at how that routing tree actually works.

The Routing Tree (Roda's Big Idea)

Roda's routing tree is the thing that makes it different from every other Ruby web framework. Not just in performance, but in how you think about URL structure. Understanding it deeply makes you a more effective Roda developer and a more effective web developer in general.

The Core Idea

In a flat router, all routes are evaluated independently:

# Flat routing (Sinatra-style)
get '/projects'          → list handler
post '/projects'         → create handler
get '/projects/:id'      → show handler
put '/projects/:id'      → update handler
delete '/projects/:id'   → delete handler

Each route is matched from scratch against every incoming request.

In Roda's tree router, routes are nested. You consume the path incrementally:

# Tree routing (Roda-style)
route do |r|
  r.on 'projects' do          # consumes "projects"
    r.is do                   # matches if nothing left
      r.get  { list }
      r.post { create }
    end

    r.on :id do               # consumes a segment, captures it
      project = Project[r.params['id']]

      r.is do                 # matches if nothing left
        r.get    { show(project) }
        r.put    { update(project) }
        r.delete { delete(project) }
      end

      r.on 'tasks' do         # consumes "tasks"
        # task routes here
      end
    end
  end
end

The path is consumed incrementally as you descend the tree. Once r.on 'projects' matches and the projects segment is consumed, the code inside that block only sees the remaining path. The projects prefix is checked once.

How Roda Actually Works Internally

Roda's routing block is a plain Ruby block that is re-executed for every request. Each call to r.on, r.is, r.get, etc. checks the current remaining path against its argument. If the check passes, it consumes the matched portion and yields. If it fails, it returns without running the block.

# Conceptually, r.on works like this:
def on(segment)
  if @remaining_path.start_with?("/#{segment}")
    old_path = @remaining_path
    @remaining_path = @remaining_path.sub("/#{segment}", '') || '/'
    result = yield  # run the block
    @remaining_path = old_path  # restore on backtrack
    result
  end
  # If not matched, just return nil
end

This "consume and restore" behavior is what makes the tree routing work correctly: if a branch doesn't match, the path is restored and the next branch can try.

The critical performance consequence: when r.on 'projects' doesn't match (because the path is /users/42), none of the code inside that block runs at all. The entire subtree of project-related routes is skipped in one comparison.

A Working Example

Let's install Roda and build a concrete routing example:

gem install roda
# routing_demo.rb
require 'roda'

class App < Roda
  route do |r|
    # Match /
    r.root do
      "Welcome to the routing demo"
    end

    # Match anything starting with /api
    r.on 'api' do
      # Match /api/v1/...
      r.on 'v1' do

        # Match /api/v1/users or /api/v1/users/...
        r.on 'users' do
          r.is do
            # Exactly /api/v1/users
            r.get  { "List users" }
            r.post { "Create user" }
          end

          r.on Integer do |id|
            # /api/v1/users/42 (Integer matcher converts to integer)
            user_info = "User ##{id}"

            r.is do
              r.get    { "Show #{user_info}" }
              r.put    { "Update #{user_info}" }
              r.delete { "Delete #{user_info}" }
            end

            r.on 'posts' do
              r.is do
                r.get  { "Posts for #{user_info}" }
                r.post { "Create post for #{user_info}" }
              end
            end
          end
        end

      end
    end
  end
end

# Run with: rackup -e 'run App'
# Or test with Rack::MockRequest:
require 'rack/mock'

def request(method, path)
  env = Rack::MockRequest.env_for(path, method: method)
  status, _, body = App.call(env)
  "#{status}: #{body.join}"
end

puts request('GET',    '/')
puts request('GET',    '/api/v1/users')
puts request('POST',   '/api/v1/users')
puts request('GET',    '/api/v1/users/42')
puts request('DELETE', '/api/v1/users/42')
puts request('GET',    '/api/v1/users/42/posts')
puts request('GET',    '/nonexistent')

Output:

200: Welcome to the routing demo
200: List users
200: Create user
200: Show User #42
200: Delete User #42
200: Posts for User #42
404:

r.on, r.is, and r.get/post/etc.

These three methods do different things:

r.on(matcher) — partial match. Matches if the path starts with the segment, then yields with the segment consumed. Used for routing prefixes.

r.on 'admin' do
  # runs for any path starting with /admin
  # remaining path is everything after /admin
end

r.is(matcher) — exact match. Matches if the matcher matches AND nothing is left in the path. Used for terminal routes.

r.on 'users' do
  r.is do
    # runs only for exactly /users, not /users/anything
  end

  r.is :id do |id|
    # runs only for exactly /users/something, captures :id
  end
end

r.get, r.post, etc. — method match. These check the HTTP method. They imply r.is when used without arguments:

r.get do
  # GET request, and nothing left in path
end

r.get 'status' do
  # GET /status (consumes "status", matches end of path)
end

When you write r.get { "hello" } inside an r.on block, you're saying: "If the HTTP method is GET AND there's nothing left in the path, run this block."

Matchers

The argument to r.on and r.is is a "matcher." Roda has several built-in matchers:

String — matches a literal path segment:

r.on 'users' do ... end       # matches /users/...
r.on 'api/v1' do ... end      # matches /api/v1/... (multi-segment)

Symbol — captures any non-empty path segment as a string:

r.on :id do |id|
  # id is a String, e.g., "42" or "alice"
end

Integer — captures a numeric path segment, converts to Integer:

r.on Integer do |id|
  # id is an Integer, e.g., 42
  # non-numeric segments don't match
end

String with captures — a regex-like pattern:

r.on /\d{4}-\d{2}-\d{2}/ do |date|
  # matches a date like 2026-02-19
end

Regexp — matches against the remaining path:

r.on /posts-(\d+)/ do |post_id|
  # captures the numeric part of "posts-42"
end

true — always matches (useful for catch-all routes):

r.on true do
  "Catch-all handler"
end

Multiple arguments — all must match (AND logic):

r.on 'projects', Integer do |id|
  # matches /projects/42, captures 42
end

Branching and Fallthrough

The routing block returns normally (not via exception) when a route matches. If nothing matches, the block returns nil, and Roda returns a 404.

This means you can have fallthrough behavior:

route do |r|
  r.on 'admin' do
    unless current_user&.admin?
      r.redirect '/login'
    end

    r.get 'dashboard' do
      "Admin dashboard"
    end
  end
end

If current_user isn't an admin, the redirect fires. If they are an admin, we proceed to the inner routes. The routing block is just Ruby — you can use conditionals, early returns, and any other control flow.

Route Variables and Scope

Because the routing block runs inside the Roda application instance, all instance methods and variables are available:

class App < Roda
  def current_user
    # ... look up user from session ...
  end

  def require_login!
    r.redirect '/login' unless current_user
  end

  route do |r|
    r.on 'account' do
      require_login!

      r.get 'profile' do
        "Profile for #{current_user.name}"
      end
    end
  end
end

require_login! is an instance method. current_user is an instance method. The routing block runs as an instance method (it's instance_exec'd), so it has access to both.

This is fundamentally different from Sinatra, where before blocks are separate from route handlers. In Roda, "before actions" are just code before the route match — you write them inline, as Ruby code.

Why This Is Better Than a Flat Router

Consider loading a project from the database. In a flat router, you do it in each handler that needs it:

# Flat router — repetitive
get '/projects/:id' do
  project = Project.find(params[:id])
  return 404 unless project
  project.to_json
end

put '/projects/:id' do
  project = Project.find(params[:id])
  return 404 unless project
  project.update(params[:data])
  project.to_json
end

delete '/projects/:id' do
  project = Project.find(params[:id])
  return 404 unless project
  project.destroy
  204
end

In Roda's tree router, you do it once:

# Tree router — DRY
r.on 'projects', Integer do |id|
  project = Project[id]
  r.halt(404, 'Not found') unless project

  r.get  { project.to_json }
  r.put  { project.update(r.params[:data]); project.to_json }
  r.delete { project.destroy; r.response.status = 204; '' }
end

The database lookup happens once, at the point where the :id segment is consumed. All handlers beneath that point get the already-loaded project. If the project doesn't exist, r.halt stops routing and returns 404 immediately.

This is not just more concise — it's correct in a way the flat version isn't. In the flat version, you can accidentally forget to load the project in one handler. In the tree version, the project is available to all inner handlers by construction.

The Insight

Here's the thing about tree routing that isn't obvious until you've worked with it: your routing code is a direct representation of your URL structure.

Look at the nested r.on calls in a Roda routing block, and you can read off the URL tree. Look at a flat list of Sinatra routes, and the URL structure is implicit — you have to mentally reconstruct it from the patterns.

When you need to add a new route under /api/v1/users/:id, you find the block that handles Integer inside the block that handles users inside the block that handles v1 inside the block that handles api. You put your new route there. It automatically benefits from any setup code (loading the user from the database, checking permissions) that already runs at that point in the tree.

This is the correctness argument for tree routing. The performance argument is real but secondary. The main reason to use Roda's routing tree is that it makes the relationship between your URL structure and your code explicit and maintainable.

Next: let's build our first real Roda application.

Your First Roda App

Theory is good. Working code is better. Let's build a complete Roda application that handles real concerns: routing, data handling, JSON responses, error handling, and a structure you could actually extend.

We'll build the same Notes API from the Rack chapter, but with Roda. Then we'll add things that would have been painful in bare Rack.

Setup

mkdir roda-notes
cd roda-notes
bundle init
# Gemfile
gem 'roda'
gem 'json'
bundle install

The Basic Application

# app.rb
require 'roda'
require 'json'

class NotesApp < Roda
  plugin :json           # Automatically serialize return values to JSON
  plugin :json_parser    # Parse JSON request bodies into r.params
  plugin :halt           # Allow r.halt for early exits
  plugin :all_verbs      # Support PUT, PATCH, DELETE in routes

  NOTES  = {}
  NEXT_ID = [1]  # Array so we can mutate it (class vars are awkward)

  route do |r|
    r.on 'notes' do

      # GET /notes - list all notes
      # POST /notes - create a note
      r.is do
        r.get do
          NOTES.values
        end

        r.post do
          content = r.params['content'].to_s
          r.halt(422, {'error' => 'content is required'}) if content.empty?

          id   = NEXT_ID[0]
          note = {'id' => id, 'content' => content, 'created' => Time.now.iso8601}
          NOTES[id] = note
          NEXT_ID[0] += 1

          response.status = 201
          note
        end
      end

      r.on Integer do |id|
        note = NOTES[id]
        r.halt(404, {'error' => 'Note not found'}) unless note

        # GET /notes/:id
        # DELETE /notes/:id
        r.is do
          r.get    { note }
          r.delete do
            NOTES.delete(id)
            response.status = 204
            ''
          end
        end
      end

    end
  end
end
# config.ru
require_relative 'app'
run NotesApp

Notice that the routing block returns the note hash directly — not a Rack response array. The json plugin intercepts the return value and serializes it. This is Roda's plugin system at work.

Running It

$ bundle exec rackup
Puma starting in single mode...
* Listening on http://127.0.0.1:9292
$ curl -s -X POST http://localhost:9292/notes \
  -H 'Content-Type: application/json' \
  -d '{"content": "First note"}' | jq .
{"id":1,"content":"First note","created":"2026-02-19T12:00:00+00:00"}

$ curl -s http://localhost:9292/notes | jq .
[{"id":1,"content":"First note","created":"..."}]

$ curl -s http://localhost:9292/notes/1 | jq .
{"id":1,"content":"First note","created":"..."}

$ curl -s -X DELETE http://localhost:9292/notes/1
# 204, empty body

$ curl -s http://localhost:9292/notes/1 | jq .
{"error":"Note not found"}

Adding Structure

As the application grows, the single route block gets large. The natural Roda approach is to extract routes into methods:

class NotesApp < Roda
  plugin :json
  plugin :json_parser
  plugin :halt
  plugin :all_verbs

  NOTES   = {}
  NEXT_ID = [1]

  route do |r|
    r.on 'notes' do
      r.is    { notes_collection(r) }
      r.on Integer do |id|
        note = find_note!(id)
        r.is { note_resource(r, note, id) }
      end
    end
  end

  private

  def find_note!(id)
    NOTES[id] || r.halt(404, {'error' => 'Note not found'})
  end

  def notes_collection(r)
    r.get  { NOTES.values }
    r.post { create_note(r) }
  end

  def note_resource(r, note, id)
    r.get    { note }
    r.delete { delete_note(id) }
  end

  def create_note(r)
    content = r.params['content'].to_s
    r.halt(422, {'error' => 'content is required'}) if content.empty?

    id   = NEXT_ID[0]
    note = {'id' => id, 'content' => content, 'created' => Time.now.iso8601}
    NOTES[id] = note
    NEXT_ID[0] += 1

    response.status = 201
    note
  end

  def delete_note(id)
    NOTES.delete(id)
    response.status = 204
    ''
  end
end

Now the route block is a compact index of what routes exist, and the implementation details are in named methods.

A More Complete Example: Multi-Resource API

Let's extend the application to handle two resources: notes and tags. Notes can have tags.

# app.rb
require 'roda'
require 'json'
require 'securerandom'

class App < Roda
  plugin :json
  plugin :json_parser
  plugin :halt
  plugin :all_verbs
  plugin :status_handler

  # Return appropriate errors for 404 and 405
  status_handler(404) { {'error' => 'Not found'} }
  status_handler(405) { {'error' => 'Method not allowed'} }

  # In-memory storage (replace with a real database in production)
  STORE = {
    notes: {},
    tags:  {},
    next_note_id: [1],
    next_tag_id:  [1],
  }

  route do |r|
    r.on 'tags' do
      r.is do
        r.get  { STORE[:tags].values }
        r.post { create_tag(r) }
      end

      r.on Integer do |id|
        tag = STORE[:tags][id]
        r.halt(404) unless tag

        r.is do
          r.get    { tag }
          r.delete { STORE[:tags].delete(id); response.status = 204; '' }
        end
      end
    end

    r.on 'notes' do
      r.is do
        r.get do
          # Support filtering by tag: GET /notes?tag=ruby
          if (tag_name = r.params['tag'])
            STORE[:notes].values.select { |n| n['tags'].include?(tag_name) }
          else
            STORE[:notes].values
          end
        end

        r.post { create_note(r) }
      end

      r.on Integer do |id|
        note = STORE[:notes][id]
        r.halt(404) unless note

        r.is do
          r.get    { note }
          r.put    { update_note(r, note) }
          r.delete { STORE[:notes].delete(id); response.status = 204; '' }
        end

        r.on 'tags' do
          r.is do
            r.get { note['tags'] }

            r.post do
              tag_name = r.params['name'].to_s
              r.halt(422, {'error' => 'name required'}) if tag_name.empty?
              note['tags'] |= [tag_name]  # union — no duplicates
              note['tags']
            end
          end

          r.on String do |tag_name|
            r.is do
              r.delete do
                note['tags'].delete(tag_name)
                response.status = 204
                ''
              end
            end
          end
        end
      end
    end
  end

  private

  def create_note(r)
    content = r.params['content'].to_s
    r.halt(422, {'error' => 'content is required'}) if content.empty?

    id = STORE[:next_note_id][0]
    STORE[:next_note_id][0] += 1

    note = {
      'id'      => id,
      'content' => content,
      'tags'    => [],
      'created' => Time.now.iso8601,
      'updated' => Time.now.iso8601,
    }
    STORE[:notes][id] = note
    response.status = 201
    note
  end

  def update_note(r, note)
    note['content'] = r.params['content'].to_s if r.params['content']
    note['updated'] = Time.now.iso8601
    note
  end

  def create_tag(r)
    name = r.params['name'].to_s
    r.halt(422, {'error' => 'name is required'}) if name.empty?

    id = STORE[:next_tag_id][0]
    STORE[:next_tag_id][0] += 1

    tag = {'id' => id, 'name' => name}
    STORE[:tags][id] = tag
    response.status = 201
    tag
  end
end

Try it:

# Create some notes
curl -s -X POST http://localhost:9292/notes \
  -H 'Content-Type: application/json' \
  -d '{"content": "Rack is a protocol"}' | jq .id

curl -s -X POST http://localhost:9292/notes \
  -H 'Content-Type: application/json' \
  -d '{"content": "Roda uses tree routing"}' | jq .id

# Tag note 1
curl -s -X POST http://localhost:9292/notes/1/tags \
  -H 'Content-Type: application/json' \
  -d '{"name": "rack"}'

curl -s -X POST http://localhost:9292/notes/1/tags \
  -H 'Content-Type: application/json' \
  -d '{"name": "ruby"}'

# Tag note 2
curl -s -X POST http://localhost:9292/notes/2/tags \
  -H 'Content-Type: application/json' \
  -d '{"name": "roda"}'

curl -s -X POST http://localhost:9292/notes/2/tags \
  -H 'Content-Type: application/json' \
  -d '{"name": "ruby"}'

# Filter by tag
curl -s 'http://localhost:9292/notes?tag=ruby' | jq 'map(.content)'
# ["Rack is a protocol", "Roda uses tree routing"]

curl -s 'http://localhost:9292/notes?tag=rack' | jq 'map(.content)'
# ["Rack is a protocol"]

# Remove a tag
curl -s -X DELETE http://localhost:9292/notes/1/tags/ruby
# 204

curl -s 'http://localhost:9292/notes?tag=ruby' | jq 'map(.content)'
# ["Roda uses tree routing"]

What Roda Added

Compare this to the bare Rack version from two chapters ago:

What got better:

  • r.halt(404) instead of manually building [404, {...}, [...]]
  • Return values are automatically serialized as JSON (no manual JSON.generate + headers)
  • r.params automatically parsed JSON body (no JSON.parse(env['rack.input'].read))
  • The routing structure mirrors the URL structure exactly
  • Database lookup (well, hash lookup) happens once for nested routes

What's still our responsibility:

  • Input validation
  • Error responses
  • Storage (we're still using an in-memory hash)
  • Authentication, authorization

What Roda didn't add that we didn't ask for:

  • Database integration
  • Template rendering
  • Sessions (unless we add the plugin)
  • CSRF protection (unless we add the plugin)

This is Roda's philosophy. The framework gives you routing and the infrastructure to opt into more. You add what you need.

The route Block Is Just Ruby

The most important thing to understand about Roda's routing is that the route block is plain Ruby code that runs for every request. There's no pre-compilation step, no route table generation at startup, no metaprogramming magic at call time.

When a request comes in for GET /notes/42, Roda:

  1. Creates a new instance of your application class
  2. Calls the route block (via instance_exec) with the request object
  3. The block runs, evaluating r.on 'notes' (which matches), then r.on Integer (which matches 42), then r.is (which matches the end), then r.get (which matches the method)
  4. The return value of the block is the response

It's that mechanical. There's no magic routing table. The routing tree is expressed as a Ruby block, and the block runs as Ruby code.

This means you can do things in routes that you can't do in a static routing table:

route do |r|
  # Routing based on configuration
  if FEATURES[:new_api]
    r.on 'api/v2' do
      # new API routes
    end
  end

  # Routing based on request data
  r.on 'api' do
    if r.env['HTTP_X_API_VERSION'] == '2'
      # version 2 handlers
    else
      # version 1 handlers
    end
  end

  # Dynamic routing (be careful with this)
  r.on String do |segment|
    page = Page.find_by_slug(segment)
    r.halt(404) unless page
    page.content
  end
end

The route block is code. Use it like code.

Now that you have a working Roda app, the next things to explore are:

  1. The plugin system — how to add features without the framework imposing them
  2. Middleware integration — how Roda works with Rack middleware
  3. Testing — which is genuinely pleasant, for reasons we'll get to

All of that is next.

Roda's Plugin System (Opt-In Everything)

Roda ships with a small core and a substantial library of optional plugins. The core gives you routing. Plugins give you everything else. This is not a compromise — it's a design philosophy with real consequences for performance, readability, and maintenance.

Why Plugins Instead of Defaults

Most frameworks come with everything on. Rails loads session handling, CSRF protection, cookie parsing, flash messages, and two dozen other features before your first line of code runs. This is convenient when you need all of those things. It's wasteful when you don't.

Roda's approach: the core does almost nothing beyond routing. Every other feature is a plugin you explicitly enable. If you don't use sessions, you don't pay for session handling. If you don't use templates, there's no template engine in memory. If you build a pure JSON API, there's no HTML-related code in your stack.

The practical consequence is a dramatically smaller memory footprint and faster boot time for applications that don't need the kitchen sink. Roda applications are consistently among the fastest Ruby web frameworks in benchmarks, and the plugin system is a significant reason why.

Loading Plugins

class App < Roda
  plugin :json             # JSON serialization of return values
  plugin :json_parser      # Parse JSON request bodies
  plugin :halt             # r.halt for early exit
  plugin :all_verbs        # PUT, PATCH, DELETE support
  plugin :status_handler   # Custom handlers for 404, 500, etc.
  plugin :sessions,        # Cookie-based sessions
    secret: ENV['SESSION_SECRET']
end

plugin is a class method that extends the application class with the plugin's capabilities. Some plugins add instance methods. Some add class-level DSL. Some modify the request or response objects.

The Core Plugins

Here's what the most commonly used plugins provide:

json and json_parser

plugin :json
plugin :json_parser

route do |r|
  r.post 'users' do
    # r.params already has the parsed JSON body (via json_parser)
    name = r.params['name']

    user = create_user(name)

    # Returning a Hash or Array automatically serializes to JSON (via json)
    user
  end
end

Without json, you'd write response['Content-Type'] = 'application/json'; JSON.generate(user) every time.

Without json_parser, you'd write JSON.parse(env['rack.input'].read) for every POST.

halt

plugin :halt

route do |r|
  r.on 'users', Integer do |id|
    user = User[id]
    r.halt(404, {'error' => 'user not found'}) unless user

    # user is definitely non-nil from here
    r.get { user }
  end
end

r.halt raises a special exception caught by Roda that immediately returns the given response. It's a clean way to implement guard clauses in routing code.

status_handler

plugin :status_handler

status_handler(404) do
  {'error' => 'Not found', 'path' => request.path}
end

status_handler(500) do |e|
  logger.error "#{e.class}: #{e.message}"
  {'error' => 'Internal server error'}
end

status_handler registers a block that runs when Roda would otherwise return that status code — including when r.halt(404) is called and when an unhandled exception occurs.

sessions

plugin :sessions, secret: ENV.fetch('SESSION_SECRET')

route do |r|
  r.post 'login' do
    user = User.authenticate(r.params['username'], r.params['password'])

    if user
      session['user_id'] = user.id
      {'status' => 'logged in'}
    else
      r.halt(401, {'error' => 'invalid credentials'})
    end
  end

  r.get 'me' do
    user_id = session['user_id']
    r.halt(401, {'error' => 'not authenticated'}) unless user_id
    User[user_id]
  end
end

Sessions are signed cookies by default. The secret is used to generate the HMAC signature that prevents tampering.

render (Tilt/ERB templates)

plugin :render, views: 'views'

route do |r|
  r.get 'users' do
    @users = User.all
    render('users/index')  # renders views/users/index.erb
  end

  r.get 'users', Integer do |id|
    @user = User[id]
    r.halt(404) unless @user
    render('users/show')
  end
end

The render plugin uses Tilt under the hood, which supports ERB, Haml, Slim, and other template engines. Instance variables set in the routing block are available in templates.

csrf

plugin :csrf

route do |r|
  # CSRF token is automatically checked on POST/PUT/PATCH/DELETE
  # You need to include the token in your forms:
  # <input type="hidden" name="_csrf" value="<%= csrf_token %>">
end

CSRF protection verifies that state-changing requests include a valid token. The csrf plugin adds this check automatically for non-GET requests, with a configurable token field name.

assets

plugin :assets,
  css: ['app.css'],
  js:  ['app.js']

route do |r|
  r.assets  # serves /assets/... in development
  # ...
end

The assets plugin handles serving static assets in development and generating fingerprinted asset paths for production.

websockets

plugin :websockets

route do |r|
  r.get 'ws' do
    r.websocket do |ws|
      ws.on(:message) { |msg| ws.send("echo: #{msg}") }
      ws.on(:close)   { puts "disconnected" }
    end
  end
end

WebSocket support as a plugin. If you don't use WebSockets, none of this code is loaded.

Writing Your Own Plugin

A Roda plugin is a Ruby module. The structure:

module Roda::RodaPlugins::MyPlugin
  # Code that runs when plugin is loaded
  def self.configure(app, opts = {})
    app.instance_variable_set(:@my_plugin_opts, opts)
  end

  # Methods added to the application class itself
  module ClassMethods
    def my_class_method
      "available as App.my_class_method"
    end
  end

  # Methods added to the application instance
  # (available in the route block and route methods)
  module InstanceMethods
    def current_user
      @current_user ||= User[session['user_id']]
    end

    def require_login!
      r.halt(401, {'error' => 'not authenticated'}) unless current_user
    end
  end

  # Methods added to r (the request object)
  module RequestMethods
    def require_permission!(permission)
      user = scope.current_user  # scope = the app instance
      unless user&.has_permission?(permission)
        halt(403, {'error' => 'forbidden'})
      end
    end
  end

  # Methods added to the response object
  module ResponseMethods
    def set_flash(message)
      self['X-Flash-Message'] = message
    end
  end
end

# Register the plugin so `plugin :my_plugin` finds it
Roda::RodaPlugins.register_plugin(:my_plugin, Roda::RodaPlugins::MyPlugin)

Use it:

class App < Roda
  plugin :my_plugin, some_option: 'value'

  route do |r|
    r.on 'protected' do
      require_login!           # from InstanceMethods
      r.require_permission!(:admin)  # from RequestMethods

      r.get { current_user }  # from InstanceMethods
    end
  end
end

This is not magic. module ClassMethods is extended into the application class. module InstanceMethods is included into the application class (so its methods become instance methods). module RequestMethods is included into Roda::RodaRequest. module ResponseMethods is included into Roda::RodaResponse.

Composing Plugins

One plugin can use another:

module Roda::RodaPlugins::RequiresJson
  def self.load_dependencies(app)
    app.plugin :json
    app.plugin :json_parser
    app.plugin :halt
  end

  module InstanceMethods
    def json_only!
      unless request.content_type&.include?('application/json')
        r.halt(415, {'error' => 'application/json required'})
      end
    end
  end
end

load_dependencies is called before configure, and loads other plugins that this plugin depends on. The dependent plugins are loaded once, even if multiple plugins list the same dependency.

The Plugin System Is Rack's Philosophy Applied Again

Rack's philosophy: define a small interface, let everything else be composed on top.

Roda's plugin philosophy: define a small core, let everything else be opted into explicitly.

Both philosophies produce systems that are:

  • Comprehensible: you can see exactly what's in your application
  • Measurable: you can benchmark only what you've loaded
  • Debuggable: when something goes wrong, you know what's running

When a Rails application behaves unexpectedly, "some middleware somewhere is doing something" is often the explanation. When a Roda application behaves unexpectedly, you look at the plugins you loaded. The list is short and you wrote it.

The tradeoff is explicit configuration. You have to know which plugins to load. You can't rely on convention as a substitute for understanding. This is not a bug — it's the point.

What the Full Stack Looks Like

A production Roda application using many features:

class App < Roda
  # Core functionality
  plugin :json
  plugin :json_parser
  plugin :halt
  plugin :all_verbs

  # Request/response helpers
  plugin :status_handler
  plugin :request_headers

  # Security
  plugin :sessions, secret: ENV.fetch('SESSION_SECRET')
  plugin :csrf

  # Content
  plugin :render, views: 'views', layout: 'layout'
  plugin :assets, css: %w[app.css], js: %w[app.js]

  # Performance
  plugin :caching       # ETag/Last-Modified support
  plugin :content_for   # Yield blocks into layouts

  # Error handling
  plugin :error_handler do |e|
    logger.error "#{e.class}: #{e.message}\n#{e.backtrace.first(10).join("\n")}"
    r.halt(500, {'error' => 'Internal server error'})
  end

  status_handler(404) { render('errors/404') }
  status_handler(403) { render('errors/403') }

  route do |r|
    r.assets

    r.on 'api' do
      # API routes — JSON responses
      r.on 'users' do
        # ...
      end
    end

    # Web routes — HTML responses
    r.get 'dashboard' do
      require_login!
      @data = load_dashboard_data
      render('dashboard')
    end
  end
end

This application has explicit sessions, CSRF protection, error handling, template rendering, and asset serving. Nothing is hidden. Every feature is a named plugin.

Next: how middleware fits into a Roda application.

Middleware in Roda

Roda is a Rack application. Everything we said about Rack middleware in Part II applies directly. But Roda also has opinions about how middleware should be organized, and it introduces a couple of patterns that are worth understanding.

Using Rack Middleware with Roda

Standard Rack middleware works identically with Roda as with any other Rack application:

class App < Roda
  use Rack::Deflater          # gzip compression
  use Rack::Session::Cookie,  # sessions (Rack's version, not Roda's)
    secret: ENV['SESSION_SECRET']

  route do |r|
    r.get 'hello' do
      'Hello, World!'
    end
  end
end

use is inherited from Rack::Builder and adds middleware to the stack that wraps your Roda application. These middlewares run before any Roda routing.

Middleware vs. Plugins

Roda has two mechanisms for adding cross-cutting behavior: Rack middleware and Roda plugins. The choice matters.

Use Rack middleware for:

  • Behavior that applies to all Rack applications uniformly (compression, request logging)
  • Third-party middleware that doesn't know about Roda
  • Middleware that needs to run before Roda even sees the request

Use Roda plugins for:

  • Behavior that needs access to Roda's routing context
  • Features that need to know about sessions, current user, or other application state
  • Authentication and authorization that should happen within routes

The practical difference: middleware runs before Roda creates a request context. Plugins run inside that context. If your authentication middleware needs to set a current_user that your routes can access, you need a plugin, because middleware has no access to the Roda application instance.

The middleware Plugin

Roda includes a middleware plugin that lets you use a Roda app as middleware inside another Roda app:

# api_app.rb
class ApiApp < Roda
  plugin :json
  plugin :middleware  # makes this usable as middleware

  route do |r|
    r.on 'api' do
      r.get 'status' do
        {'status' => 'ok', 'app' => 'api'}
      end
    end
  end
end

# web_app.rb
class WebApp < Roda
  use ApiApp  # ApiApp handles /api/* requests, passes others through

  route do |r|
    r.get '/' do
      'Main web app'
    end
  end
end

When ApiApp receives a request that doesn't match any of its routes, it calls the next application in the stack (the @app in standard middleware terms). This is what the middleware plugin adds — the pass-through behavior.

Without plugin :middleware, a Roda app that doesn't match a route returns 404. With it, the app passes the request to the next layer.

Building an Auth Middleware in Roda Style

Here's a common pattern: an authentication middleware that sets the current user in the env, which is then read by the application's plugin:

# middleware/authenticate.rb
class Authenticate
  def initialize(app, header: 'HTTP_AUTHORIZATION')
    @app    = app
    @header = header
  end

  def call(env)
    token = env[@header]&.sub('Bearer ', '')

    if token
      user = User.find_by_token(token)
      env['current_user'] = user if user
    end

    @app.call(env)
  end
end
# plugins/current_user.rb
module Roda::RodaPlugins::CurrentUser
  module InstanceMethods
    def current_user
      # Read from env, set by the middleware
      env['current_user']
    end

    def require_authenticated!
      r.halt(401, {'error' => 'Authentication required'}) unless current_user
    end

    def require_admin!
      require_authenticated!
      r.halt(403, {'error' => 'Forbidden'}) unless current_user.admin?
    end
  end
end
# app.rb
class App < Roda
  use Authenticate  # runs before Roda, populates env['current_user']

  plugin :json
  plugin :halt
  plugin :current_user  # reads env['current_user'] in route context

  route do |r|
    r.get 'public' do
      'No auth required'
    end

    r.on 'private' do
      require_authenticated!  # from plugin

      r.get 'data' do
        {'user' => current_user.name, 'data' => [1, 2, 3]}
      end
    end

    r.on 'admin' do
      require_admin!  # from plugin

      r.get 'users' do
        User.all
      end
    end
  end
end

The middleware handles authentication (token verification, user lookup). The plugin provides ergonomic access to the result within Roda's context. Clean separation of concerns.

Conditional Middleware

Because Roda is a class and use is a class method, you can conditionally load middleware:

class App < Roda
  use Rack::Deflater                    # always compress
  use RequestLogger if $stdout.tty?    # log to console in development
  use Sentry::Rack::CaptureExceptions  # error tracking in production

  route do |r|
    # ...
  end
end

Or based on environment:

class App < Roda
  if ENV['RACK_ENV'] == 'development'
    use Rack::Lint   # validates Rack compliance
    use BetterErrors::Middleware, allow_ip: '127.0.0.1'
  end

  route do |r|
    # ...
  end
end

Middleware Ordering with Roda

The middleware ordering rules from Part II still apply, but Roda adds one consideration: plugins that modify request or response handling (like sessions or csrf) run inside the Roda instance, after all Rack middleware. Rack-level session middleware runs before Roda; Roda's session plugin runs inside Roda.

This means you shouldn't mix Rack session middleware with Roda's session plugin:

# Don't do this
class App < Roda
  use Rack::Session::Cookie, secret: 'secret1'  # Rack-level sessions
  plugin :sessions, secret: 'secret2'           # Roda-level sessions
  # They'll conflict — two different session stores
end

# Do this instead
class App < Roda
  plugin :sessions, secret: ENV['SESSION_SECRET']  # Roda handles sessions
end

Roda's session plugin uses a slightly different cookie format than Rack::Session::Cookie, so if you're migrating from a Rack session setup, you'll need to handle the transition carefully.

Inspecting the Middleware Stack

To see what middleware is in your stack:

# In a Rack app or Rails console
App.middleware.each { |m| p m }

Or at the Rack level:

# config.ru
require_relative 'app'

# Introspect before running
puts "Middleware stack:"
app = App
while app.respond_to?(:app)
  puts "  #{app.class}"
  app = app.app
end

run App

In production, it's worth auditing your middleware stack. Every middleware is code that runs for every request. If you have middleware you're not using, remove it.

The Right Mental Model

Think of a Roda application as three nested layers:

[Rack middleware layer]
  Rack::Deflater
  YourAuthMiddleware
  Rack::RequestId
    ↓ env hash passes through here
[Roda routing layer]
  plugin :sessions reads/writes cookies
  plugin :csrf validates tokens
  r.on / r.is / r.get match paths and methods
    ↓ reaches your route block
[Your application layer]
  current_user
  business logic
  data access

Middleware is for infrastructure concerns that don't need to know they're running in Roda. Plugins are for application concerns that benefit from Roda's routing context.

When you're unsure which to use: if it needs access to session, current_user, or any Roda-specific context, it's a plugin. If it treats the request as an opaque Rack env hash, it's middleware.

Next: testing Roda applications, which is one of the genuine pleasures of this stack.

Testing Roda Apps

Testing Roda applications is straightforward, and that's not an accident. Because a Roda app is a Rack-compatible callable, testing it requires no test server, no HTTP round-trips, and no framework-specific test DSL. You call call, you check the result.

Let's build a complete test suite.

Setup

# Gemfile
gem 'roda'
gem 'rack-test'   # Rack testing helpers
gem 'minitest'    # or rspec, your choice
gem 'json'

rack-test is a gem that provides a convenient DSL on top of Rack::MockRequest. It's the standard testing library for Rack applications, and it works with any Rack-compatible framework.

The Application Under Test

# app.rb
require 'roda'
require 'json'

class App < Roda
  plugin :json
  plugin :json_parser
  plugin :halt
  plugin :all_verbs
  plugin :sessions, secret: 'test-secret-at-least-64-bytes-long-for-production-use'

  STORE = Hash.new { |h, k| h[k] = {} }
  NEXT_ID = Hash.new(1)

  route do |r|
    r.on 'notes' do
      r.is do
        r.get  { STORE[:notes].values }
        r.post { create_note(r) }
      end

      r.on Integer do |id|
        note = STORE[:notes][id]
        r.halt(404, {'error' => 'not found'}) unless note

        r.is do
          r.get    { note }
          r.put    { update_note(r, note) }
          r.delete { STORE[:notes].delete(id); response.status = 204; '' }
        end
      end
    end

    r.on 'auth' do
      r.post 'login' do
        if r.params['password'] == 'correct-password'
          session['user'] = r.params['username']
          {'status' => 'logged in', 'user' => r.params['username']}
        else
          r.halt(401, {'error' => 'invalid credentials'})
        end
      end

      r.get 'whoami' do
        user = session['user']
        r.halt(401, {'error' => 'not authenticated'}) unless user
        {'user' => user}
      end

      r.delete 'logout' do
        session.clear
        response.status = 204
        ''
      end
    end
  end

  private

  def create_note(r)
    content = r.params['content'].to_s
    r.halt(422, {'error' => 'content required'}) if content.empty?

    id = NEXT_ID[:note]
    NEXT_ID[:note] += 1

    note = {'id' => id, 'content' => content, 'created' => Time.now.iso8601}
    STORE[:notes][id] = note
    response.status = 201
    note
  end

  def update_note(r, note)
    note['content'] = r.params['content'] if r.params['content']
    note['updated'] = Time.now.iso8601
    note
  end
end

Testing with rack-test

# test/test_app.rb
require 'minitest/autorun'
require 'rack/test'
require 'json'
require_relative '../app'

class AppTest < Minitest::Test
  include Rack::Test::Methods

  def app
    App  # rack-test calls App.call(env) for each request
  end

  # Reset storage before each test
  def setup
    App::STORE.clear
    App::NEXT_ID.clear
  end

  # --- Notes ---

  def test_empty_notes_list
    get '/notes'
    assert_equal 200, last_response.status
    assert_equal 'application/json', last_response.content_type
    assert_equal [], JSON.parse(last_response.body)
  end

  def test_create_note
    post_json '/notes', content: 'Test note'
    assert_equal 201, last_response.status

    data = parse_response
    assert_equal 'Test note', data['content']
    assert_kind_of Integer, data['id']
    assert data['created']
  end

  def test_create_note_requires_content
    post_json '/notes', {}
    assert_equal 422, last_response.status
    assert_equal 'content required', parse_response['error']
  end

  def test_get_note
    post_json '/notes', content: 'My note'
    id = parse_response['id']

    get "/notes/#{id}"
    assert_equal 200, last_response.status
    assert_equal 'My note', parse_response['content']
  end

  def test_get_missing_note
    get '/notes/999'
    assert_equal 404, last_response.status
    assert_equal 'not found', parse_response['error']
  end

  def test_update_note
    post_json '/notes', content: 'Original'
    id = parse_response['id']

    put_json "/notes/#{id}", content: 'Updated'
    assert_equal 200, last_response.status
    assert_equal 'Updated', parse_response['content']
    assert parse_response['updated']
  end

  def test_delete_note
    post_json '/notes', content: 'To delete'
    id = parse_response['id']

    delete "/notes/#{id}"
    assert_equal 204, last_response.status

    get "/notes/#{id}"
    assert_equal 404, last_response.status
  end

  def test_multiple_notes
    3.times { |i| post_json '/notes', content: "Note #{i}" }

    get '/notes'
    notes = JSON.parse(last_response.body)
    assert_equal 3, notes.length
  end

  # --- Authentication ---

  def test_login_success
    post_json '/auth/login',
      username: 'alice',
      password: 'correct-password'

    assert_equal 200, last_response.status
    assert_equal 'alice', parse_response['user']
  end

  def test_login_failure
    post_json '/auth/login',
      username: 'alice',
      password: 'wrong-password'

    assert_equal 401, last_response.status
    assert_equal 'invalid credentials', parse_response['error']
  end

  def test_whoami_when_not_authenticated
    get '/auth/whoami'
    assert_equal 401, last_response.status
  end

  def test_whoami_when_authenticated
    post_json '/auth/login', username: 'alice', password: 'correct-password'

    get '/auth/whoami'
    assert_equal 200, last_response.status
    assert_equal 'alice', parse_response['user']
  end

  def test_logout
    post_json '/auth/login', username: 'alice', password: 'correct-password'
    get '/auth/whoami'
    assert_equal 200, last_response.status

    delete '/auth/logout'
    assert_equal 204, last_response.status

    get '/auth/whoami'
    assert_equal 401, last_response.status
  end

  # --- Helper methods ---

  private

  def post_json(path, data)
    post path, data.to_json, 'CONTENT_TYPE' => 'application/json'
  end

  def put_json(path, data)
    put path, data.to_json, 'CONTENT_TYPE' => 'application/json'
  end

  def parse_response
    JSON.parse(last_response.body)
  end
end

Run it:

$ ruby test/test_app.rb
Run options: --seed 42

# Running:

.............

Finished in 0.023s, 565.2 runs/s.
13 runs, 22 assertions, 0 failures, 0 errors, 0 skips

Thirteen tests, 23 milliseconds. No HTTP server, no database, no external dependencies.

What rack-test Provides

Rack::Test::Methods mixes in methods:

  • get(path, params, headers) — GET request
  • post(path, body, headers) — POST request
  • put(path, body, headers) — PUT request
  • patch(path, body, headers) — PATCH request
  • delete(path, params, headers) — DELETE request
  • last_response — the response from the last request
  • last_request — the request that was sent

last_response has:

  • .status — integer status code
  • .body — response body string
  • .headers — response headers hash
  • .content_type — Content-Type header

Sessions are maintained between requests automatically — rack-test includes cookies in subsequent requests. This is how the authentication test flow works: login, then whoami, without any manual cookie handling.

Testing Without rack-test

You don't need rack-test. It's convenient, but you can call your Roda app directly:

class MinimalTest < Minitest::Test
  def make_request(method, path, body: nil, headers: {})
    env = Rack::MockRequest.env_for(path,
      method: method,
      input:  body ? StringIO.new(body) : StringIO.new,
      'CONTENT_TYPE' => headers['Content-Type'] || 'application/json',
    )
    App.call(env)
  end

  def test_get_notes
    status, headers, body = make_request('GET', '/notes')
    assert_equal 200, status
    assert_equal [], JSON.parse(body.join)
  end
end

This is more verbose but requires no additional gems. It's what rack-test does internally.

Integration Tests vs. Unit Tests

The tests above are integration tests — they exercise the full routing stack. For complex business logic, you'll also want unit tests that test specific methods in isolation:

class NoteCreationTest < Minitest::Test
  def test_note_content_validation
    app = App.new({})  # Roda app instance — this is unusual but possible

    # Test helper methods directly if they're accessible
    # Usually better to test through the HTTP interface
  end
end

In practice, for Roda applications, the HTTP-level integration tests are usually sufficient. The routing layer is thin — it delegates to methods or objects that contain business logic. Test those objects directly, and test the routing integration through HTTP.

Testing Middleware

To test middleware in isolation, wrap it around a simple app:

class MiddlewareTest < Minitest::Test
  include Rack::Test::Methods

  def app
    # Wrap the middleware around a simple echo app
    echo_app = lambda { |env| [200, {'Content-Type' => 'text/plain'}, ['ok']] }
    Rack::Builder.new do
      use RateLimiter, limit: 3
      run echo_app
    end
  end

  def test_allows_requests_under_limit
    3.times do
      get '/'
      assert_equal 200, last_response.status
    end
  end

  def test_blocks_requests_over_limit
    3.times { get '/' }
    get '/'
    assert_equal 429, last_response.status
  end
end

Test the middleware, not the application. Middleware should have no knowledge of the application it wraps, and its tests should reflect that.

Test Structure for Real Applications

For a larger application, organize tests by resource:

test/
  test_helper.rb       # Minitest + rack-test setup, shared helpers
  integration/
    test_notes.rb      # Notes resource tests
    test_auth.rb       # Authentication tests
    test_users.rb      # User resource tests
  unit/
    test_note.rb       # Note model/service tests (no HTTP)
    test_auth_service.rb
  middleware/
    test_rate_limiter.rb
# test/test_helper.rb
require 'minitest/autorun'
require 'rack/test'
require 'json'
require_relative '../app'

class IntegrationTest < Minitest::Test
  include Rack::Test::Methods

  def app
    App
  end

  def setup
    App::STORE.clear  # or reset your database
  end

  def post_json(path, data)
    post path, data.to_json, 'CONTENT_TYPE' => 'application/json'
  end

  def put_json(path, data)
    put path, data.to_json, 'CONTENT_TYPE' => 'application/json'
  end

  def parse_response
    JSON.parse(last_response.body)
  end

  def assert_status(expected)
    assert_equal expected, last_response.status,
      "Expected status #{expected}, got #{last_response.status}: #{last_response.body}"
  end
end
# test/integration/test_notes.rb
require_relative '../test_helper'

class NotesTest < IntegrationTest
  def test_crud_lifecycle
    # Create
    post_json '/notes', content: 'Learn Roda'
    assert_status 201
    id = parse_response['id']

    # Read
    get "/notes/#{id}"
    assert_status 200
    assert_equal 'Learn Roda', parse_response['content']

    # Update
    put_json "/notes/#{id}", content: 'Understand Roda'
    assert_status 200
    assert_equal 'Understand Roda', parse_response['content']

    # Delete
    delete "/notes/#{id}"
    assert_status 204

    get "/notes/#{id}"
    assert_status 404
  end
end

The Insight

Testing Roda applications is fast because there's no framework overhead, no HTTP round-trips, and no external services. The test suite from this chapter runs in milliseconds. A test suite with 500 tests that each take 2ms is done in a second.

Compare to a Rails application where integration tests spin up a test server, make HTTP requests, and wait for responses. Rails tests are slower partly because Rails does more, and partly because the abstraction layers make it harder to test at the right level.

Roda's thin stack means you can always test at exactly the right level: HTTP integration tests for routing concerns, unit tests for business logic, middleware tests for middleware. There's no intermediate layer that's difficult to test in isolation.

This is the compounding benefit of understanding your tools. When you understand what a Roda application is — a Rack callable with a routing block — you know exactly how to test it. No test framework magic required.

Roll Your Own Mini-Framework (For Fun and Understanding)

Everything you've learned in this book is now in service of one exercise: building a small but complete web framework from scratch. Not a toy — a usable framework with routing, middleware support, a plugin system, and a test helper.

This is not an exercise in "why bother with frameworks?" Frameworks exist because this exercise, repeated at production scale, is what produces them. The point is to internalize the patterns by building them, so that you can read Roda's source, or Rails's source, or any Ruby web framework's source, and recognize what you're looking at.

The Design

Our framework will be called Kiwi. It will have:

  • Rack compatibility (obviously)
  • A routing DSL with HTTP method helpers
  • A simple plugin system
  • A before/after filter mechanism
  • A test helper
  • About 150 lines of code
# kiwi.rb
require 'rack'

module Kiwi
  class Application
    # Class-level state
    class << self
      def routes
        @routes ||= Hash.new { |h, k| h[k] = [] }
      end

      def filters
        @filters ||= {before: [], after: []}
      end

      def plugins
        @plugins ||= []
      end

      # HTTP method DSL
      def get(path, &handler)    = add_route('GET',    path, &handler)
      def post(path, &handler)   = add_route('POST',   path, &handler)
      def put(path, &handler)    = add_route('PUT',    path, &handler)
      def patch(path, &handler)  = add_route('PATCH',  path, &handler)
      def delete(path, &handler) = add_route('DELETE', path, &handler)

      def before(&block) = filters[:before] << block
      def after(&block)  = filters[:after]  << block

      def plugin(mod)
        extend mod::ClassMethods  if mod.const_defined?(:ClassMethods)
        include mod::InstanceMethods if mod.const_defined?(:InstanceMethods)
        mod.setup(self) if mod.respond_to?(:setup)
        plugins << mod
      end

      # Make the class itself a Rack app
      def call(env)
        new(env).dispatch
      end

      # Middleware support via Rack::Builder
      def use(middleware, *args, **kwargs, &block)
        @builder ||= Rack::Builder.new
        @builder.use(middleware, *args, **kwargs, &block)
        @builder.run(method(:dispatch_directly))
      end

      def to_app
        if @builder
          @builder.to_app
        else
          method(:dispatch_directly)
        end
      end

      private

      def dispatch_directly(env)
        new(env).dispatch
      end

      def add_route(method, path, &handler)
        pattern, param_names = compile_pattern(path)
        routes[method] << {pattern: pattern, params: param_names, handler: handler}
      end

      def compile_pattern(path)
        param_names = []
        pattern_str = path.gsub(/:(\w+)/) do
          param_names << $1
          '([^/]+)'
        end
        pattern = Regexp.new("\\A#{pattern_str}\\z")
        [pattern, param_names]
      end
    end

    # Instance methods (one instance per request)
    attr_reader :env, :request, :response, :params

    def initialize(env)
      @env      = env
      @request  = Rack::Request.new(env)
      @response = Rack::Response.new
      @params   = {}
    end

    def dispatch
      method = env['REQUEST_METHOD']
      path   = env['PATH_INFO']

      # Run before filters
      self.class.filters[:before].each { |f| instance_exec(&f) }

      result = find_and_run_route(method, path)

      # Run after filters
      self.class.filters[:after].each { |f| instance_exec(&f) }

      # Handle the result
      case result
      when Array  # raw Rack response
        result
      when String
        response.write(result)
        response.finish
      else
        response.status = 404
        response.write('Not Found')
        response.finish
      end
    rescue HaltError => e
      e.response
    end

    private

    def find_and_run_route(method, path)
      candidates = self.class.routes[method] || []

      candidates.each do |route|
        match = route[:pattern].match(path)
        next unless match

        # Populate params hash
        route[:params].each_with_index do |name, i|
          @params[name] = match[i + 1]
        end
        @params.merge!(request.params)

        return instance_exec(@params, &route[:handler])
      end

      nil  # no match → 404
    end
  end

  # Early exit mechanism
  class HaltError < StandardError
    attr_reader :response

    def initialize(status, body = '', headers = {})
      @response = [status, {'Content-Type' => 'text/plain'}.merge(headers), [body]]
    end
  end

  module InstanceMethods
    def halt(status, body = '', headers = {})
      raise HaltError.new(status, body, headers)
    end

    def redirect(location, status = 302)
      halt(status, '', 'Location' => location)
    end

    def json(data, status: 200)
      require 'json'
      body = JSON.generate(data)
      response.status = status
      response['Content-Type'] = 'application/json'
      response.write(body)
      response.finish
    end
  end

  Application.include(InstanceMethods)
end

That's the core. ~120 lines. Let's verify it works:

# test_kiwi.rb
require_relative 'kiwi'
require 'rack/mock'
require 'json'

class BlogApp < Kiwi::Application
  POSTS = {}
  NEXT  = [1]

  before do
    response['X-Framework'] = 'Kiwi/0.1'
  end

  get '/' do |params|
    json({posts: POSTS.count, message: 'Welcome to KiwiBlog'})
  end

  get '/posts' do |params|
    json(POSTS.values)
  end

  post '/posts' do |params|
    id   = NEXT[0]
    NEXT[0] += 1
    post = {id: id, title: params['title'], body: params['body']}
    POSTS[id] = post
    json(post, status: 201)
  end

  get '/posts/:id' do |params|
    post = POSTS[params['id'].to_i]
    halt(404, 'Post not found') unless post
    json(post)
  end

  delete '/posts/:id' do |params|
    halt(404) unless POSTS.delete(params['id'].to_i)
    response.status = 204
    ''
  end
end

# Test it
def req(method, path, body: nil)
  env = Rack::MockRequest.env_for(path,
    method: method,
    input:  body ? StringIO.new(body) : StringIO.new,
    'CONTENT_TYPE' => 'application/json'
  )
  BlogApp.call(env)
end

status, headers, body = req('GET', '/')
puts "#{status}: #{body.join}"
# 200: {"posts":0,"message":"Welcome to KiwiBlog"}
puts headers['X-Framework']
# Kiwi/0.1

status, headers, body = req('POST', '/posts',
  body: '{"title":"Hello","body":"World"}'
)
puts "#{status}: #{body.join}"
# 201: {"id":1,"title":"Hello","body":"World"}

status, _, body = req('GET', '/posts/1')
puts "#{status}: #{body.join}"
# 200: {"id":1,"title":"Hello","body":"World"}

status, _, _ = req('DELETE', '/posts/1')
puts status
# 204

status, _, body = req('GET', '/posts/1')
puts "#{status}: #{body.join}"
# 404: Post not found

Adding the Plugin System

# Add to Kiwi::Application class methods:

def self.plugin(mod)
  mod.extend(mod::ClassMethods) if mod.const_defined?(:ClassMethods)
  mod.setup(self) if mod.respond_to?(:setup)

  include mod::InstanceMethods if mod.const_defined?(:InstanceMethods)
end

# A plugin: JSON request body parsing
module Kiwi
  module JsonBody
    def self.setup(app)
      # no class-level setup needed
    end

    module InstanceMethods
      def json_body
        return {} unless env['CONTENT_TYPE']&.include?('application/json')
        require 'json'
        @json_body ||= JSON.parse(request.body.read)
      rescue JSON::ParserError
        halt(400, 'Invalid JSON')
      end
    end
  end

  # A plugin: simple authentication
  module SimpleAuth
    def self.setup(app)
      app.before do
        @authenticated = session_valid?
      end
    end

    module InstanceMethods
      def authenticated?
        @authenticated
      end

      def require_auth!
        halt(401, 'Unauthorized') unless authenticated?
      end

      private

      def session_valid?
        # In real life: check a session cookie or Authorization header
        env['HTTP_AUTHORIZATION'] == 'Bearer valid-token'
      end
    end
  end
end

# Using plugins:
class SecureApp < Kiwi::Application
  plugin Kiwi::JsonBody
  plugin Kiwi::SimpleAuth

  get '/public' do |_|
    'Anyone can see this'
  end

  get '/private' do |_|
    require_auth!
    json({secret: 'classified data'})
  end

  post '/data' do |_|
    require_auth!
    data = json_body
    json({received: data}, status: 201)
  end
end

Adding a Test Helper

# kiwi/test_helper.rb
module Kiwi
  module TestHelper
    def self.included(base)
      base.include(InstanceMethods)
    end

    module InstanceMethods
      def app_class
        raise NotImplementedError, "Define app_class in your test"
      end

      def get(path, headers: {})
        call_app('GET', path, headers: headers)
      end

      def post(path, body: nil, headers: {})
        call_app('POST', path, body: body, headers: headers)
      end

      def put(path, body: nil, headers: {})
        call_app('PUT', path, body: body, headers: headers)
      end

      def delete(path, headers: {})
        call_app('DELETE', path, headers: headers)
      end

      def post_json(path, data)
        post(path,
          body: JSON.generate(data),
          headers: {'CONTENT_TYPE' => 'application/json'}
        )
      end

      def last_status  = @last_response[0]
      def last_headers = @last_response[1]
      def last_body    = @last_response[2].join
      def last_json    = JSON.parse(last_body)

      private

      def call_app(method, path, body: nil, headers: {})
        env = Rack::MockRequest.env_for(path,
          {method: method, input: body ? StringIO.new(body) : StringIO.new}
            .merge(headers)
        )
        @last_response = app_class.call(env)
      end
    end
  end
end

# Using it:
require 'minitest/autorun'
require_relative 'kiwi/test_helper'
require_relative 'blog_app'

class BlogAppTest < Minitest::Test
  include Kiwi::TestHelper

  def app_class = BlogApp
  def setup    = BlogApp::POSTS.clear

  def test_home
    get '/'
    assert_equal 200, last_status
    assert_equal 0, last_json['posts']
  end

  def test_create_and_read_post
    post_json '/posts', title: 'Test', body: 'Content'
    assert_equal 201, last_status
    id = last_json['id']

    get "/posts/#{id}"
    assert_equal 200, last_status
    assert_equal 'Test', last_json['title']
  end
end

What 150 Lines Taught Us

Our mini-framework implements:

  • ✅ Rack compliance
  • ✅ HTTP method routing (GET, POST, PUT, PATCH, DELETE)
  • ✅ Path parameter extraction
  • ✅ Query parameter merging
  • ✅ Before/after filters
  • ✅ JSON helpers
  • ✅ Early exit via halt
  • ✅ Redirect helper
  • ✅ Plugin system
  • ✅ Test helper

What it doesn't have:

  • ❌ Tree routing (all routes are flat, O(n) matching)
  • ❌ Route priorities
  • ❌ Template rendering
  • ❌ Sessions
  • ❌ CSRF protection
  • ❌ Asset handling
  • ❌ WebSocket support
  • ❌ Streaming responses
  • ❌ Content negotiation
  • ❌ A decade of bug fixes

The gap between our 150 lines and Roda's ~3,000 lines (core only) is exactly those missing features, plus the handling of edge cases we haven't considered (unusual HTTP methods, malformed requests, encoding issues, thread safety).

The gap between Roda and Rails is the additional 60,000+ lines that provide ActiveRecord, ActionView, ActionMailer, and the rest.

None of those additional lines are magic. They're solutions to real problems, written by people who understood exactly the same foundation we just built.

Where to Go From Here

If you want to keep exploring:

  • Read Roda's source — it's well-organized and the plugins are independently readable
  • Read Rack's source — the middleware in the gem are good examples of the pattern
  • Read Sinatra's source — it's a single large file and instructive in how a flat DSL framework is built
  • Look at Cuba — a micro-framework even simpler than Kiwi that influenced Roda

The pattern you've built is the pattern everything uses. The rest is features.

Rails vs Sinatra vs Roda (Now That You Know What They Are)

Having built a Rack server, written middleware, implemented a router, and assembled a mini-framework, we're in a position to compare these three frameworks with precision rather than opinion.

This is not a benchmark article. Benchmarks are useful but narrow. This is a comparison of design philosophies, and what those philosophies mean for the kind of applications each framework is appropriate for.

The Common Foundation

All three frameworks:

  • Are valid Rack applications — they respond to call(env) and return [status, headers, body]
  • Can use any Rack-compatible server (Puma, Unicorn, Falcon, WEBrick)
  • Can use any Rack middleware
  • Can be mounted inside each other

Here's the proof of the last point:

# A Rails app mounting a Sinatra app mounting a Roda app
# This is contrived, but it works.

# roda_api.rb
class RodaApi < Roda
  plugin :json
  route do |r|
    r.get 'status' do
      {'roda' => true}
    end
  end
end

# sinatra_app.rb
require 'sinatra/base'
class SinatraApp < Sinatra::Base
  get '/sinatra/status' do
    content_type :json
    {sinatra: true}.to_json
  end

  # Mount the Roda app
  map('/roda') { run RodaApi }
end

# In config/routes.rb (Rails)
Rails.application.routes.draw do
  mount SinatraApp => '/sinatra-land'
  # GET /sinatra-land/sinatra/status → Sinatra handles it
  # GET /sinatra-land/roda/status   → Roda handles it
end

This works because they all speak Rack. mount in Rails routes calls the mounted app's call method for matching requests.

Rails

What it is: A full-stack web framework with conventions for everything. Database ORM (ActiveRecord), view rendering (ActionView), email (ActionMailer), jobs (ActiveJob), WebSockets (ActionCable), asset compilation, and more — all integrated and conventionally configured.

What it gives you:

  • rails generate scaffolds entire CRUD resources in seconds
  • ActiveRecord is a mature, powerful ORM with a large ecosystem of gems
  • Rails's conventions mean you can pick up a Rails project and know where things live
  • ActiveSupport adds useful Ruby core extensions
  • Security features (CSRF, XSS protection) on by default
  • The largest Ruby web framework community, by far

What it costs:

  • Memory: a minimal Rails app uses ~70-100MB of RAM at startup, before your code runs
  • Boot time: several seconds in development, longer in large applications
  • Complexity: the middleware stack, before/after callback chains, and concerns can make tracing execution surprisingly difficult
  • Convention lock-in: stepping outside Rails conventions (different ORMs, custom routing) requires fighting the framework

When to choose Rails:

  • You're building an application that fits the Rails sweet spot: database-backed web application with HTML views, standard CRUD operations, authenticated users
  • You value the breadth of the ecosystem (authentication gems like Devise, admin interfaces like ActiveAdmin, pagination, etc.)
  • Your team already knows Rails
  • You need to move fast and the application is relatively conventional

When Rails is the wrong choice:

  • Pure JSON APIs where ActiveRecord's overhead isn't needed
  • Applications with unusual routing requirements (Rails's router is powerful but the flat route list doesn't scale as well as a tree)
  • High-traffic applications where memory and boot time matter significantly
  • Microservices where you want minimal dependencies
# Rails: conventions do a lot of work
class ProjectsController < ApplicationController
  before_action :authenticate!
  before_action :set_project, only: [:show, :update, :destroy]

  def index
    @projects = current_user.projects
    render json: @projects
  end

  def show
    render json: @project
  end

  def create
    @project = current_user.projects.build(project_params)
    if @project.save
      render json: @project, status: :created
    else
      render json: @project.errors, status: :unprocessable_entity
    end
  end

  private

  def set_project
    @project = current_user.projects.find(params[:id])
  end

  def project_params
    params.require(:project).permit(:name, :description)
  end
end

Sinatra

What it is: A minimal web DSL. Routes, filters, helpers, template rendering. Nothing else.

What it gives you:

  • Simple, readable routing DSL
  • Template rendering via Tilt (ERB, Haml, Slim, Markdown, etc.)
  • Before/after filters
  • Helper methods via helpers do
  • Very fast startup time

What it costs:

  • Flat route list (same performance concerns as our hand-rolled router for large apps)
  • No built-in solution for many common concerns (sessions via Rack middleware, auth you write yourself)
  • Routing doesn't compose well — there's no natural way to express hierarchical routes
  • The DSL can mislead you: Sinatra looks simple but some things (understanding scope in blocks, class vs. instance methods) are surprising

When to choose Sinatra:

  • Small APIs with a modest number of routes
  • Webhook receivers
  • Development tools and internal utilities
  • Prototypes
  • Applications where you want to understand exactly what's in your stack

When Sinatra is the wrong choice:

  • Large applications with complex routing (the flat route list becomes a maintenance burden)
  • Applications that need the performance of tree routing
  • Any application where you'd reinvent what Roda provides
# Sinatra: explicit and readable, but flat
class App < Sinatra::Base
  before do
    halt 401 unless authenticated?
  end

  get '/projects' do
    content_type :json
    Project.all.to_json
  end

  get '/projects/:id' do
    project = Project.find_by(id: params[:id])
    halt 404 unless project
    content_type :json
    project.to_json
  end

  post '/projects' do
    project = Project.create(params[:project])
    content_type :json
    status 201
    project.to_json
  end
end

Roda

What it is: A Rack framework built around a routing tree and an opt-in plugin system.

What it gives you:

  • Tree routing that scales to large applications
  • Plugin system that keeps your footprint small
  • Correct routing semantics (shared setup for nested routes)
  • Fast — consistently one of the fastest Ruby web frameworks
  • Excellent test ergonomics
  • A codebase small enough to read and understand fully

What it costs:

  • Smaller ecosystem than Rails (fewer ready-made gems designed for Roda)
  • No ORM included (you choose Sequel, ActiveRecord, or ROM)
  • More explicit configuration (everything is opt-in, nothing is automatic)
  • Less documentation and fewer tutorials than Rails

When to choose Roda:

  • JSON APIs, especially those with complex routing
  • Applications where memory and performance are important
  • Teams that value explicit over implicit
  • Applications using Sequel (Jeremy Evans authored both)
  • When you want a framework whose internals you can actually understand

When Roda is the wrong choice:

  • When you need the Rails ecosystem (Devise, RailsAdmin, etc.) specifically
  • Teams that are deeply invested in Rails conventions and don't want to change
# Roda: hierarchical, DRY, explicit
class App < Roda
  plugin :json
  plugin :json_parser
  plugin :halt
  plugin :all_verbs

  route do |r|
    authenticate!

    r.on 'projects' do
      r.is do
        r.get  { Project.all }
        r.post do
          project = Project.create(r.params['project'])
          response.status = 201
          project
        end
      end

      r.on Integer do |id|
        project = Project[id]
        r.halt(404, {'error' => 'not found'}) unless project

        # All three handlers share the already-loaded project
        r.get    { project }
        r.put    { project.update(r.params['project']); project }
        r.delete { project.destroy; response.status = 204; '' }
      end
    end
  end
end

The Actual Decision Criteria

When choosing a framework, the questions that matter:

1. What is the team's existing knowledge? A team that knows Rails deeply will be more productive with Rails than with Roda, even if Roda is theoretically a better fit. Retraining time is a real cost.

2. What does the application actually need? If you need ActiveRecord's specific feature set (STI, counter caches, extensive callbacks), Rails's integration with it is valuable. If you're choosing your ORM separately (Sequel, ROM), Roda or Sinatra might be cleaner.

3. What are the performance requirements? A small API that gets 100 requests per second doesn't need Roda's performance advantages. An API getting 10,000 requests per second might care very much.

4. How complex is the routing? 10 routes: all three work fine. 50 routes: Rails and Roda both handle it; Sinatra starts to get unwieldy. 200 routes: Roda's tree routing is a genuine advantage.

5. How much do you value explicitness? Rails convention is powerful when you know what the conventions are and your application follows them. When you step outside them, it's friction. Roda's opt-in approach means you always know what's running.

What They Actually Share

This is the more important point: all three frameworks are doing the same fundamental thing. They receive a Rack env hash, they route the request to a handler, the handler returns a response, the response is sent.

The differences are:

  • How routing is expressed and evaluated
  • What defaults are provided
  • What the plugin/gem ecosystem looks like
  • Memory and performance characteristics

None of these differences are about what's possible. Everything you can build with Rails, you can build with Roda or Sinatra. The differences are about convenience, convention, and performance.

When you know Rack, you know that Rails isn't doing magic — it's routing to a callable and returning a response array. When you know routing is pattern matching, you can read any framework's routing code. When you know middleware is just objects wrapping other objects, you can debug middleware issues in any framework.

The framework you choose matters less than you probably thought before reading this book. Your understanding of what it's doing matters considerably more.

A Note on Performance Numbers

Roda is consistently faster than Sinatra and dramatically faster than Rails in benchmarks. Some rough ordering for a "hello world" endpoint:

FrameworkThroughput (relative)Memory
Roda~100%~30MB
Sinatra~60-70%~40MB
Rails~20-30%~100MB

These numbers are meaningless without context. A "hello world" benchmark measures framework overhead, not application overhead. In a real application:

  • Your business logic usually dominates the response time
  • Database queries are usually the bottleneck, not routing
  • Memory differences matter at scale (100 processes × 70MB difference = 7GB)

The performance advantage of Roda matters when you're CPU-bound on framework overhead, which happens at high traffic volume or in very thin services. For most applications, pick the framework your team knows best and optimize later if needed.

The understanding advantage — knowing what your framework is doing — matters always.

You Know Too Much Now (What to Do With It)

You started this book knowing how to use Ruby web frameworks. You end it knowing what they are.

The gap between "knowing how to use" and "knowing what it is" is the difference between operating a tool and understanding it. Both are useful. The second makes you significantly more capable with the first.

Let's account for what actually changed.

What You Now Know

HTTP is text with a defined format. A request is a method, a path, headers, and an optional body — all ASCII text over a TCP socket. A response is a status code, headers, and a body. You've built both from raw sockets. This means you can debug HTTP problems at the wire level, read network traces, and understand error messages that previously seemed cryptic.

Rack is a contract. The Rack spec defines one thing: your application receives a hash, it returns a three-element array. That contract is what makes every Ruby web framework interoperable. Rails, Sinatra, and Roda all honor it. You've read the spec, built conforming applications, built a conforming server, and written middleware. The contract is no longer abstract.

Middleware is function composition. An object that wraps another object and delegates to it, with behavior added before or after. That's it. You've written several middlewares. You can now read any middleware in any framework and understand exactly what it does, because they all use the same pattern.

Routing is pattern matching. A router maintains a list (or tree) of (method, pattern, handler) tuples. When a request arrives, it's matched against the list. You've built a flat router. You've used Roda's tree router. You understand why tree routing has better performance characteristics and better structural properties for large applications.

Frameworks are solutions to repeated problems. Rails solves the problem of building database-backed web applications with HTML views at scale. Sinatra solves the problem of writing small web services without a lot of boilerplate. Roda solves the problem of routing and opt-in features for applications that need both correctness and performance. None of them are magic. You've built a mini-framework that demonstrates the core of what all of them do.

What You Can Do With It

Read framework source code. Before reading this book, opening actiondispatch/routing.rb or roda.rb probably felt like reading a different language. Now it's Ruby solving problems you understand. When something in your application behaves unexpectedly, you can follow the execution into the framework and find the answer.

Write middleware confidently. Request logging, authentication, rate limiting, content negotiation, CORS headers — these all belong in middleware. You know the pattern. You know where they go in the stack. You know how to test them.

Debug routing problems. Routing bugs in production are some of the most disorienting because the framework seems to be lying to you. Now you know what the framework is doing. You can inspect the route table, add logging middleware to see what the env contains before routing, and identify whether the problem is in the route definition or the request.

Make informed framework choices. When someone proposes switching from Sinatra to Rails (or from Rails to Roda, or from Roda to anything else), you can evaluate the trade-offs with precision. Not "Rails has more features" or "Roda is faster" — specific statements about routing architecture, memory footprint, middleware organization, and ecosystem breadth.

Evaluate Rack middleware gems. The Ruby ecosystem has hundreds of Rack middleware gems for caching, authentication, logging, profiling, and more. You can now read any of them, understand what they do, and decide whether they're appropriate for your stack.

Build things the framework doesn't support. Sometimes you need something the framework doesn't provide. Before this book, that meant searching for a gem or filing a feature request. Now it might mean writing fifty lines of Rack middleware that solve exactly your problem without any framework coupling.

The Unsexy Part

There's a tendency in the software field to fetishize "building from scratch" as a virtue. It isn't. The appropriate level of abstraction for any given task is the one that lets you solve the problem without drowning in detail.

For most web applications, Rails is not only appropriate — it's excellent. The conventions are sound. ActiveRecord is battle-tested. The ecosystem is mature. "Building from scratch because you understand how it works" is not a good reason to write your own ORM.

What this book gives you is not a reason to abandon your existing tools. It gives you the ability to use them with full understanding. That's different, and more valuable.

The developer who knows Rails inside-out and understands the Rack layer beneath it will outperform the developer who knows only Rails, and will outperform the developer who insists on building everything from scratch. Understanding the foundation makes you better at using the abstractions built on it.

A Few Things Worth Doing

If you want to reinforce what you've learned:

Read Rack's middleware implementations. Rack::Session::Cookie, Rack::Runtime, Rack::Deflater — these are all short, well-written, and instructive. They're in your gems directory right now.

Read Roda's core. The main roda.rb file is about 1,000 lines and implements everything we discussed in Part III. Read it front to back. You'll recognize every pattern.

Open a Rails console and poke the middleware stack. Rails.application.middleware.each { |m| p m }. Find the session middleware. Find the router. Understand what order they're in and why.

Write a middleware for an existing application. Add request timing headers. Add a custom logger that formats output the way you want. Add a middleware that blocks requests from specific IP ranges. These are practical, finite exercises that solidify the pattern.

Read the Rack spec. The actual Rack specification is short enough to read in fifteen minutes. You know enough now to understand every word of it.

The Last Thing

At the beginning of this book, we said that Rails is just a callable. You may have believed it as a statement, but not felt it as a truth.

By now you've built a callable that speaks HTTP. You've built a server that calls callables. You've built middleware that wraps callables in other callables. You've built a routing tree that dispatches to callables. You've seen how Roda is a callable, and how its plugins and route handlers are all ultimately callables.

When you now hear "Rails is just a callable," you know it's true in the same way you know that a skyscraper is just concrete and steel. The material is simple. The engineering is sophisticated. Knowing the material doesn't diminish the engineering — it lets you appreciate it more accurately.

Go build something. You know what it's made of.