Skip to content
← All posts
5 min read Dawid Skłodowski

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::Attack is) — 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.