Rack Middleware from Scratch: Build Your Own Ruby Middleware
Rack is the foundation that powers nearly every Ruby web framework. Whether you use Rails, Sinatra, or Hanami, at their core these frameworks all speak Rack. Understanding how Rack middleware works gives you deep insight into how requests flow through your application and empowers you to intercept, modify, or reject requests at any point in that flow.
You will build Rack middleware from scratch, learning the exact contract that middleware must fulfill, how the middleware stack processes requests, and how to chain multiple middleware pieces together.
Before you start
Middleware is one of those concepts that feels abstract until you trace a request from top to bottom. Once you do, the model gets much easier to reason about: a request enters the stack, each middleware layer gets a chance to inspect or modify it, and the response walks back out through the same layers in reverse.
That shape is why Rack stays so useful. It gives Ruby web frameworks a common agreement about request and response objects, which means the same middleware ideas apply whether you are debugging Rails or writing something much smaller.
What is Rack middleware?
Rack middleware is a class that sits between your web server and your application. It can inspect incoming requests, modify them, pass them along to the next piece of the stack, and then inspect or modify the response before it returns to the client.
The key insight is this: middleware is just a wrapper. Each piece of middleware receives a request, does something with it, then either handles the request completely or passes it to the next middleware (or the final app). This creates a chain where each link can transform the request or response.
If you keep that mental model in mind, the rest of the examples become much easier to follow. Every class in the stack has the same job shape, so the differences between them are mostly about when they short-circuit and what they add on the way through.
The middleware contract
Any valid Rack middleware must follow a simple contract. It needs to:
- Accept an
appin its constructor - Define a
call(env)method that takes the environment hash and returns a three-element array:[status, headers, body]
The environment hash is a plain Ruby Hash containing all request information. Here are the most important keys you will work with:
REQUEST_METHOD: The HTTP verb (GET, POST, PUT, etc.)PATH_INFO: The path being requestedQUERY_STRING: Everything after the?in the URLHTTP_*: Custom headers (e.g.,HTTP_USER_AGENT)rack.input: The request body as an IO-like object
Building your first middleware
Create a new Ruby file and build a simple logging middleware that records every request:
class LoggerMiddleware
def initialize(app)
@app = app
end
def call(env)
method = env['REQUEST_METHOD']
path = env['PATH_INFO']
timestamp = Time.now.utc.iso8601
puts "[#{timestamp}] #{method} #{path}"
@app.call(env)
end
end
This middleware does three things:
- Extracts the HTTP method and path from the environment
- Logs the request with a timestamp
- Passes the request to the next piece in the chain (the app)
Notice that @app.call(env) returns the three-element response array. We return that same array from our middleware, which is why it works as a pass-through by default.
This is the simplest useful middleware shape: read a little, log something, then hand the request back to the next layer unchanged. Starting with pass-through behavior makes it easier to add more advanced features later because you always know what the baseline response looks like.
Building a request timer
Let us build something more useful: a timing middleware that measures how long the request took to process:
class TimerMiddleware
def initialize(app)
@app = app
end
def call(env)
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
status, headers, body = @app.call(env)
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
duration = ((end_time - start_time) * 1000).round(2)
headers['X-Response-Time'] = "#{duration}ms"
[status, headers, body]
end
end
This middleware wraps the app call. It captures the start time, lets the request proceed through the app, captures the end time, and adds a custom header with the duration. The key difference from the logger is that we unpack the response, modify the headers, and return the modified response.
Building a conditional redirect middleware
Now let us build middleware that can reject requests entirely. This redirect middleware sends users to a different path if they try to access a specific route:
class RedirectMiddleware
def initialize(app, from:, to:)
@app = app
@from = from
@to = to
end
def call(env)
path = env['PATH_INFO']
if path.start_with?(@from)
new_path = path.sub(@from, @to)
query = env['QUERY_STRING']
redirect_url = "#{env['SCRIPT_NAME']}#{new_path}"
redirect_url += "?#{query}" unless query.nil? || query.empty?
return [301, {'Location' => redirect_url}, ['Redirecting...']]
end
@app.call(env)
end
end
This middleware checks if the requested path starts with a certain prefix. If it does, it returns a 301 redirect response with the new location. Otherwise, it passes the request along normally.
Debugging a middleware stack
When a stack stops behaving the way you expect, the fastest fix is to print what enters and leaves each layer. Middleware is just Ruby code, so you can temporarily log the request path, headers, and response status at each hop. That makes it much easier to see whether the problem lives in the middleware itself or in the app behind it.
One useful habit is to keep middleware tiny and focused. A redirect layer should only redirect, a timer should only measure time, and a logger should only record information. If one class starts doing all three, the stack becomes harder to reason about and the order of execution becomes much less obvious. Small middleware objects are easier to test, easier to reorder, and easier to remove when you no longer need them.
Another practical trick is to short-circuit early when the request clearly does not belong to the middleware. For example, a health check middleware should only handle /health, and everything else should pass straight through. That pattern keeps response time predictable and helps avoid accidental coupling between layers.
If you are unsure where a request is getting lost, add a temporary puts statement or a logger call inside call(env). Once you see the request path and response status in order, the bug usually becomes much easier to spot.
Combining middleware with Rack::Builder
Now that you have three middleware classes, let us combine them into a stack. Rack provides Rack::Builder for this purpose:
require 'rack'
# Your simple Rack app
class App
def call(env)
[200, {'Content-Type' => 'text/plain'}, ['Hello from the app!']]
end
end
# Build the middleware stack
app = Rack::Builder.new do
use RedirectMiddleware, from: '/old', to: '/new'
use TimerMiddleware
use LoggerMiddleware
run App.new
end
# Run it with Rack handler
Rack::Handler::WEBrick.run(app, Port: 3000)
The order matters. When a request comes in, it flows through the middleware in this order:
RedirectMiddlewarefirst : can redirect before anything else happensTimerMiddlewaresecond : wraps the timing around everything below itLoggerMiddlewarethird : logs after the request completesAppfinally : the actual application
The response then flows back up through the same middleware in reverse order, which is why middleware can modify both the request going down and the response coming back.
Alternative: using use directly
You can also use the simpler use method directly without Rack::Builder:
app = RedirectMiddleware.new(
TimerMiddleware.new(
LoggerMiddleware.new(
App.new
)
)
)
This manual nesting produces the same result as using Rack::Builder, just with more explicit control over the order. Each middleware wraps the next one in the chain.
Understanding the response format
All Rack responses must be a three-element array:
- Status : An integer HTTP status code (200, 404, 500, etc.)
- Headers : A hash of response headers
- Body : An enumerable that yields the response body strings
Here is a JSON response example:
def call(env)
json = JSON.generate({ message: 'Hello' })
[200, {'Content-Type' => 'application/json'}, [json]]
end
Notice that the body is an array containing a single string. This is valid because the body just needs to be enumerable.
Middleware that modifies request bodies
Reading and modifying request bodies is trickier because the body can only be read once. Here is a middleware that reads the body, modifies it, and replaces it in the environment:
class BodyReaderMiddleware
def initialize(app)
@app = app
end
def call(env)
# Read the request body
body = env['rack.input'].read
env['rack.input'].rewind # Allow downstream to read it again
# Store parsed body in env for downstream apps
env['request.body'] = body
@app.call(env)
end
end
The rack.input key contains the request body as an IO-like object. After reading it, you must call rewind if anything else needs to read it.
That detail is easy to miss the first time you work with request bodies. If you forget to rewind, downstream code may see an empty body and behave as if the client sent nothing at all.
Next steps
You have built several middleware pieces from scratch and learned how to combine them into a stack. This pattern is exactly how Rails, Sinatra, and other frameworks organize their request processing.
To continue learning:
- Explore how Rails implements its own middleware stack
- Learn about Rack’s built-in middleware like
Rack::StaticorRack::Deflater - Build a simple authentication middleware that checks for valid tokens
If you want to build a full application on top of the same Rack ideas, continue with Build Your Own Web Framework in Ruby. That tutorial uses the same request and response shape to show how a tiny framework can grow into something more complete.
See Also
- Ruby Modules and Mixins: Understanding modules helps you organize middleware code
- Ruby Hashes: The env hash is just a Ruby hash, so mastering hashes makes working with Rack easier
- Ruby Methods: Methods are the building blocks of middleware classes