Rack Middleware From Scratch — What Lives Between the Internet and Your Rails App
Before a request reaches your controller it passes through roughly twenty pieces of code you didn’t write. Session handling, cookie parsing, parameter decoding, flash messages, exception rendering — all of it is Rack middleware, and all of it follows one small interface you can implement in nine lines. Understanding that stack turns a category of mysterious behaviour into something you can inspect, reorder, and extend.
The Entire Rack Contract
A Rack application is any object that responds to call(env) and returns a three-element array: status, headers, body.
Example:
app = ->(env) { [200, { "content-type" => "text/plain" }, ["Hello"]] }
Middleware is a Rack application that wraps another one. It takes the next app in its constructor, and its call decides what to do before, after, or instead of delegating.
Example:
class RequestTimer
def initialize(app)
@app = app
end
def call(env)
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
status, headers, body = @app.call(env)
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
headers["x-runtime-ms"] = (elapsed * 1000).round(2).to_s
[status, headers, body]
end
end
That’s the whole pattern. Every middleware in Rails — ActionDispatch::Cookies, Rack::MethodOverride, ActionDispatch::ShowExceptions — is this shape with more code inside.
Seeing Your Actual Stack
bin/rails middleware
Output:
use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use Rack::MethodOverride
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use ActionDispatch::DebugExceptions
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
run YourApp::Application.routes
Read it top to bottom for the request path and bottom to top for the response path. Order is not decorative — ActionDispatch::Flash sits below Session::CookieStore because flash is stored in the session, and putting it above would break it.
Writing One That Earns Its Place
A realistic example: rejecting oversized JSON bodies before Rails parses them, because parsing a 40MB payload just to reject it wastes the memory you were trying to protect.
Example:
# app/middleware/payload_limiter.rb
class PayloadLimiter
DEFAULT_LIMIT = 1.megabyte
def initialize(app, limit: DEFAULT_LIMIT, paths: [])
@app = app
@limit = limit
@paths = paths
end
def call(env)
return @app.call(env) unless applicable?(env)
length = env["CONTENT_LENGTH"].to_i
return too_large(length) if length > @limit
@app.call(env)
end
private
def applicable?(env)
return false unless %w[POST PUT PATCH].include?(env["REQUEST_METHOD"])
return true if @paths.empty?
@paths.any? { |prefix| env["PATH_INFO"].start_with?(prefix) }
end
def too_large(length)
body = { error: "payload_too_large", limit_bytes: @limit, received_bytes: length }.to_json
[413, { "content-type" => "application/json" }, [body]]
end
end
Registering it:
# config/application.rb
config.middleware.insert_before ActionDispatch::Cookies,
PayloadLimiter,
limit: 2.megabytes,
paths: ["/api/"]
Note the position — before cookies and session, because there’s no reason to decode a session for a request you’re about to reject.
Insertion Methods
config.middleware.use Something # append to the end
config.middleware.insert_before ActionDispatch::Static, X # specific position
config.middleware.insert_after Rack::Runtime, Y
config.middleware.swap ActionDispatch::ShowExceptions, Z # replace in place
config.middleware.delete Rack::ETag # remove
config.middleware.move_after Rack::Head, ActionDispatch::Flash
use puts your middleware at the bottom of the stack, just above the router — which means it runs last on the way in and first on the way out. That’s fine for something that only inspects the response, wrong for anything that should short-circuit early.
The Body Is an Enumerable, Not a String
The most common bug in hand-written middleware. The third element of the Rack triplet responds to each, and it might be a streaming object that yields chunks over time.
Example:
# Broken — assumes body is an array of one string
def call(env)
status, headers, body = @app.call(env)
body[0] = body[0].gsub("foo", "bar")
[status, headers, body]
end
That breaks on ActionDispatch::Response::RackBody, on file bodies, and on anything streamed. The correct form buffers explicitly:
Example:
def call(env)
status, headers, body = @app.call(env)
buffered = +""
body.each { |chunk| buffered << chunk }
body.close if body.respond_to?(:close)
modified = buffered.gsub("foo", "bar")
headers["content-length"] = modified.bytesize.to_s
[status, headers, [modified]]
end
Two details people miss: close must be called on the original body or you leak file handles, and content-length must be recalculated in bytes, not characters, or multibyte content truncates in the client.
Better still, don’t rewrite bodies in middleware unless you have to. Buffering defeats streaming, and the correct place for content transformation is usually a view layer that knows what it’s rendering.
Reading the Request Properly
env is a hash of CGI-style keys and it’s unpleasant to work with directly. Wrap it.
Example:
def call(env)
request = Rack::Request.new(env)
request.path # "/api/orders"
request.request_method # "POST"
request.params # merged query and form params
request.get_header("HTTP_X_REQUEST_ID")
request.ip
request.media_type # "application/json"
@app.call(env)
end
In a Rails app, ActionDispatch::Request adds more — but only if your middleware runs below the point where those features are set up. Above ActionDispatch::Cookies, request.cookies is empty because nothing has parsed them yet. This is the single most common source of “why is this nil in my middleware.”
Passing data down the stack
Use a namespaced env key. Everything below you can read it, including controllers via request.env.
env["myapp.request_started_at"] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
Namespace it with your app name. Unprefixed keys collide with Rack’s own and with other gems.
Pro-Tip: Middleware runs outside the Rails executor unless you place it below
ActionDispatch::Executor, and that has a concrete consequence people discover the hard way: if your middleware checks out an ActiveRecord connection — a feature flag lookup, an API key validation, a tenant resolution query — and it sits above the executor, that connection is never returned to the pool. Under load the pool exhausts and the app deadlocks withActiveRecord::ConnectionTimeoutErrorand no obvious culprit in the trace. Either insert belowActionDispatch::Executor, or wrap your database work inActiveRecord::Base.connection_pool.with_connection { ... }explicitly. If your middleware touches the database at all, ask whether it should be abefore_actioninstead — a controller filter gets connection management, error handling, and instrumentation for free.
Exceptions and Ordering
An exception raised in middleware above ActionDispatch::ShowExceptions never reaches Rails’ error page or your exception tracker’s Rails integration — it propagates to the web server and produces a bare 500 with no logging. Middleware that can fail belongs below ShowExceptions, or must rescue its own errors:
Example:
def call(env)
@app.call(env)
rescue MyMiddlewareError => e
Rails.logger.error("PayloadLimiter: #{e.message}")
[500, { "content-type" => "text/plain" }, ["Internal Server Error"]]
end
Testing Middleware
It’s a plain object with one method, so test it directly. No request cycle needed.
Example:
RSpec.describe PayloadLimiter do
let(:downstream) { ->(_env) { [200, {}, ["ok"]] } }
let(:middleware) { described_class.new(downstream, limit: 100) }
def env_for(method:, path:, length:)
Rack::MockRequest.env_for(path, method: method).merge("CONTENT_LENGTH" => length.to_s)
end
it "passes small payloads through" do
status, = middleware.call(env_for(method: "POST", path: "/api/orders", length: 50))
expect(status).to eq(200)
end
it "rejects oversized payloads with 413" do
status, _headers, body = middleware.call(env_for(method: "POST", path: "/api/orders", length: 500))
expect(status).to eq(413)
expect(JSON.parse(body.first)["error"]).to eq("payload_too_large")
end
it "ignores GET requests" do
status, = middleware.call(env_for(method: "GET", path: "/api/orders", length: 500))
expect(status).to eq(200)
end
end
Rack::MockRequest.env_for builds a valid env hash, and a lambda stands in for the rest of the stack. These specs run in milliseconds and cover the ordering logic that request specs would obscure.
Conclusion
Rack middleware is one method and a three-element array, and that simplicity is why the entire Rails request pipeline is built from it. The things that make hand-written middleware go wrong are all positional: reading cookies above the cookie parser, checking out connections above the executor, raising above ShowExceptions, buffering a streaming body. Run bin/rails middleware, decide where your code belongs in that list, and prefer a before_action whenever the work needs anything Rails sets up for you. When it genuinely belongs at the edge — rate limiting, payload guards, request tagging, health checks that must answer while the app is degraded — middleware is the right and cheapest tool.
FAQs
Q1: Where should middleware files live in a Rails app?
app/middleware/ works with autoloading, but classes referenced in config/application.rb load before the autoloader is ready. Either use a string name (config.middleware.use "PayloadLimiter") or put the file in lib/ and require it explicitly.
Q2: Why are my header names lowercase?
Rack 3 requires lowercase header keys. Rack 2 was case-insensitive in practice. If you’re on Rack 3, headers["Content-Type"] and headers["content-type"] are different keys — use lowercase.
Q3: Can middleware modify the request before Rails sees it?
Yes — mutate env before calling @app.call(env). This is how Rack::MethodOverride turns a POST with _method=DELETE into a DELETE.
Q4: Does middleware run for Action Cable or Active Storage?
Action Cable mounts as a separate Rack app and doesn’t share the full stack. Active Storage runs through the normal Rails stack as ordinary controllers, so yes.
Q5: How do I skip middleware for specific paths?
Check env["PATH_INFO"] at the top of call and delegate immediately when it doesn’t apply. There’s no built-in path-scoping for middleware — you branch inside it, which is also why the check should be the first thing in the method.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀