Rack middleware: what happens before your controller
Every Rails request passes through a stack of Rack middleware before it ever reaches your controller. A deep-dive on what Rack is, how the middleware stack works, and how to write your own for logging, auth, and more.
Most Rails developers work happily for years without ever thinking about what happens
before a request reaches a controller action. But there is a whole layer down there
— Rack, and the stack of middleware sitting on top of it — that handles sessions,
parses parameters, sets cookies, and a dozen other things, all before your def show
ever runs. Understanding it turns a class of “magic” into something you can see,
debug, and extend. Here is what Rack is and how to use it.
Rack: the contract under every Ruby web app
Rack is a beautifully small idea: a standard interface between Ruby web servers and
Ruby web frameworks. A Rack application is anything that responds to call, takes an
environment hash, and returns an array of three things: a status code, a headers
hash, and a body that responds to each.
class HelloApp
def call(env)
[200, { "Content-Type" => "text/plain" }, ["Hello from Rack"]]
end
end
That is a complete, runnable web application. env is a hash describing the request —
the path, method, headers, query string, everything. The return value is the
response. This single convention is why Puma, Unicorn, and WEBrick can all run Rails,
Sinatra, and your three-line app interchangeably: they all speak Rack. Rails itself is,
at the bottom, a very sophisticated Rack application.
Middleware: a stack of wrappers
Middleware is the genuinely powerful part. A piece of Rack middleware is an object that
wraps another Rack app — it takes the next app in its constructor, and its call
can do something before passing env down, and something to the response on the way
back up:
class RequestTimer
def initialize(app)
@app = app
end
def call(env)
started = Time.now
status, headers, body = @app.call(env) # call the rest of the stack
duration = ((Time.now - started) * 1000).round
headers["X-Runtime-Ms"] = duration.to_s
[status, headers, body]
end
end
Because each middleware wraps the next, they compose into an onion: a request passes inward through each layer to the application at the core, and the response passes back outward through the same layers in reverse. Every middleware gets to see and modify the request on the way in and the response on the way out. This is the structure that lets cross-cutting concerns — things every request needs — live in one place instead of being repeated in every controller.
Rails is middleware all the way down
The reason this matters for Rails developers is that a huge amount of what you think of as “Rails” is actually middleware. You can see the whole stack:
$ rake middleware
use Rack::Sendfile
use ActionDispatch::Static
use Rack::Runtime
use ActionDispatch::RequestId
use Rack::MethodOverride
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use Rack::Head
run YourApp::Application.routes
Reading that list is illuminating. Cookies, sessions, the flash, parameter parsing,
the _method override that lets a form do a PATCH — none of these are controller
features; they are middleware that has already run by the time your action executes.
The env hash arrives at your controller pre-populated by everything above. Once you
have read rake middleware, a lot of Rails “magic” turns into “oh, that’s the
Cookies middleware”.
Writing and inserting your own
You add middleware to the stack in config/application.rb, choosing where it sits
relative to the existing ones:
# config/application.rb
config.middleware.use RequestTimer
config.middleware.insert_before ActionDispatch::Cookies, MaintenanceMode
config.middleware.insert_after Rack::Runtime, RequestLogger
Position matters, because it determines what has run already. Middleware near the top runs first on the way in and last on the way out, and sees the rawest request; middleware near the bottom runs just before your app and sees everything the upper layers added. A few things middleware is the right tool for:
-
A maintenance mode. Check for a flag and short-circuit with a 503 before the request ever touches Rails — no controller, no database, just a flat response:
class MaintenanceMode def initialize(app); @app = app; end def call(env) if File.exist?("tmp/maintenance.txt") [503, { "Content-Type" => "text/html" }, ["<h1>Back soon</h1>"]] else @app.call(env) end end end -
Cross-cutting logging, metrics, and request IDs that should apply to every request uniformly, controllers and assets alike.
-
Rejecting bad traffic early — blocklisting an IP, enforcing HTTPS, rate limiting (this is exactly what
Rack::Attackis) — before it consumes any application resources. -
Health-check endpoints that must respond even if the app behind them is struggling, because they sit in front of it.
The guiding principle: middleware is for concerns that are about the request itself and apply broadly, especially when you want to handle them before — or instead of — the full Rails stack. Things that need your domain models or are specific to one action belong in the controller; things that apply to every request and want to run early belong in middleware.
When not to reach for it
Middleware is powerful enough to misuse. Business logic does not belong there — it has no clean access to your models or routing context and becomes hard to test and find. If a junior developer goes looking for “what handles refunds”, they will look in controllers and services, not in a Rack middleware, so putting domain logic in the stack hides it. Keep middleware to genuinely request-level, cross-cutting concerns, and keep the count modest — every middleware runs on every request, so a bloated stack is a tax on all of them.
Verdict
Rack is one of the most elegant abstractions in the Ruby ecosystem: a three-element
contract that lets servers, frameworks, and middleware all interoperate, and a
stack-of-wrappers model that makes cross-cutting concerns composable. For a Rails
developer, understanding it pays off twice — it demystifies a large slice of what the
framework does for you, and it gives you a clean, powerful place to handle the things
that apply to every request before they reach your controllers. Run rake middleware,
read the stack, write one small middleware, and a layer that used to be invisible
becomes a tool you reach for deliberately.