Custom Rack Middleware in Rails 8: A Practical Guide with Real Examples
Every Rails request passes through a stack of middleware before it reaches your controller. Most developers never think about this stack until something breaks — a missing request ID in logs, CORS headers that vanish in production, or response times that spike without explanation.
Rails 8 ships with about 25 middleware by default. You can see yours right now:
bin/rails middleware
Each middleware is a simple Ruby object that receives a request, optionally modifies it, passes it down the stack, and optionally modifies the response on the way back up. That’s it. No magic, no framework — just the Rack interface.
The Rack interface in 30 seconds
A Rack middleware is any object that responds to call(env) and returns a three-element array: [status, headers, body]. Here’s the skeleton:
class MyMiddleware
def initialize(app)
@app = app
end
def call(env)
# Before the request hits Rails
status, headers, body = @app.call(env)
# After the response comes back
[status, headers, body]
end
end
The app argument is the next middleware in the stack (or your Rails app at the bottom). You call it, get the response, and return it. That’s the entire contract.
Example 1: Request timing middleware
Let’s build something useful. This middleware measures how long each request takes and adds the timing as a response header:
# lib/middleware/request_timer.rb
class RequestTimer
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["X-Request-Time"] = format("%.4f", elapsed)
[status, headers, body]
end
end
Register it in config/application.rb:
require_relative "../lib/middleware/request_timer"
module MyApp
class Application < Rails::Application
config.middleware.use RequestTimer
end
end
Using Process.clock_gettime(Process::CLOCK_MONOTONIC) instead of Time.now matters here. Monotonic clocks aren’t affected by NTP adjustments or system clock changes, so your timings stay accurate even during daylight saving transitions or clock syncs.
You can verify it works:
curl -I http://localhost:3000/
# X-Request-Time: 0.0234
Example 2: Tenant detection for multi-tenant apps
In a multi-tenant SaaS app, you often need to identify the tenant before any controller code runs. Middleware is the right place for this:
# lib/middleware/tenant_resolver.rb
class TenantResolver
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
host = request.host
tenant = Tenant.find_by(domain: host)
if tenant
env["app.current_tenant"] = tenant
@app.call(env)
else
[404, { "content-type" => "text/plain" }, ["Unknown tenant"]]
end
end
end
Your controllers can then access the tenant through request.env["app.current_tenant"]. This runs before routing, before authentication, before everything — which is exactly what you want for tenant isolation.
If you’re building a multi-tenant Rails application, putting tenant resolution in middleware keeps it out of ApplicationController callbacks where it’s harder to guarantee execution order.
Where your middleware goes in the stack
This is where people get tripped up. The order matters, and Rails gives you four methods to control it:
# Add to the end of the stack
config.middleware.use MyMiddleware
# Add before a specific middleware
config.middleware.insert_before ActionDispatch::Static, MyMiddleware
# Add after a specific middleware
config.middleware.insert_after Rails::Rack::Logger, MyMiddleware
# Replace an existing middleware
config.middleware.swap ActionDispatch::ShowExceptions, MyCustomExceptions
A common mistake: placing timing middleware after ActionDispatch::Static. Static file requests get served before your middleware ever sees them, so your timing numbers miss those requests entirely. If you want to time everything, insert before ActionDispatch::Static:
config.middleware.insert_before ActionDispatch::Static, RequestTimer
Run bin/rails middleware after making changes to verify your middleware landed where you expected.
Example 3: Request ID propagation for distributed tracing
Rails 8 includes ActionDispatch::RequestId by default, which generates a UUID for each request. But in microservice architectures, you want to propagate an existing request ID from upstream services:
# lib/middleware/request_id_propagator.rb
class RequestIdPropagator
HEADER = "X-Request-Id"
def initialize(app)
@app = app
end
def call(env)
# Trust upstream request ID if present, generate otherwise
request_id = env["HTTP_X_REQUEST_ID"] || SecureRandom.uuid
env["HTTP_X_REQUEST_ID"] = request_id
status, headers, body = @app.call(env)
headers[HEADER] = request_id
[status, headers, body]
end
end
Insert this before ActionDispatch::RequestId so Rails picks up your propagated ID instead of generating a new one:
config.middleware.insert_before ActionDispatch::RequestId, RequestIdPropagator
This pairs well with structured logging — once every log line includes the request ID, tracing a request across services becomes straightforward.
Testing middleware in isolation
You don’t need to boot Rails to test middleware. Rack middleware is just Ruby:
# test/middleware/request_timer_test.rb
require "test_helper"
require "rack/test"
class RequestTimerTest < ActiveSupport::TestCase
include Rack::Test::Methods
def app
inner_app = ->(env) { [200, {}, ["OK"]] }
RequestTimer.new(inner_app)
end
test "adds X-Request-Time header" do
get "/"
assert last_response.headers.key?("X-Request-Time")
assert_match(/\d+\.\d{4}/, last_response.headers["X-Request-Time"])
end
test "passes through status and body" do
get "/"
assert_equal 200, last_response.status
assert_equal "OK", last_response.body
end
end
The inner_app lambda acts as a stand-in for the rest of your application. No database, no routes, no Rails boot — tests run in milliseconds.
Handling response bodies correctly
One gotcha that bites people in production: Rack response bodies must respond to each. If your middleware modifies the body, you need to handle this properly:
def call(env)
status, headers, body = @app.call(env)
# Wrong: body might be a Rack::BodyProxy, not a string
# modified = body + "<script>...</script>"
# Right: collect the body first
response_body = []
body.each { |chunk| response_body << chunk }
body.close if body.respond_to?(:close)
modified = response_body.join
# Now you can modify it
modified.gsub!("</body>", "<script>...</script></body>")
headers["content-length"] = modified.bytesize.to_s
[status, headers, [modified]]
end
Always call body.close if the body responds to it. Skipping this leaks file descriptors when Rails streams responses from disk.
Performance considerations
Middleware runs on every single request. A middleware that takes 1ms adds 1ms to every page load, every API call, every health check. That compounds fast.
Rules I follow in production:
- No database queries in middleware unless absolutely necessary (tenant resolution is one exception). Use caching if you must query.
- No heavy computation. If you need to parse request bodies, do it lazily.
- Short-circuit early. If your middleware only applies to certain paths, check the path first and call
@app.call(env)immediately for non-matching requests. - Measure your middleware. The request timing middleware above is useful for measuring everything else, but who measures the measurer? Add logging during development if you suspect middleware overhead.
def call(env)
# Short-circuit for paths we don't care about
return @app.call(env) unless env["PATH_INFO"].start_with?("/api")
# Expensive logic only for API requests
# ...
end
If you’re seeing unexplained response time increases, Rails performance profiling with YJIT can help identify whether middleware is the culprit.
Conditional middleware in Rails 8
Sometimes you want middleware only in certain environments. Rails makes this clean:
# config/environments/development.rb
config.middleware.use WebConsole::Middleware
# config/environments/production.rb
config.middleware.use RateLimiter, requests_per_minute: 60
You can also use config.middleware.delete to remove default middleware you don’t need:
# If you're API-only and don't serve static files
config.middleware.delete ActionDispatch::Static
# If you handle cookies differently
config.middleware.delete ActionDispatch::Cookies
For API-only Rails apps (config.api_only = true), Rails already strips several middleware (cookies, session, flash). Check what’s left with bin/rails middleware before adding more.
When not to use middleware
Middleware isn’t always the answer. Use a controller concern or before_action when:
- The logic only applies to specific controllers or actions
- You need access to Rails routing information (params, current user)
- The behavior depends on the response content type
Use middleware when:
- The logic should run for every request regardless of route
- You’re working at the HTTP level (headers, timing, request IDs)
- You want the logic to run before Rails routing
- You need to modify responses after the controller has finished
If you’re adding authentication or authorization checks, those usually belong in controllers or a dedicated authentication layer, not middleware. Middleware can’t easily access your User model or session — it runs too early in the stack.
FAQ
How many middleware is too many in a Rails app?
There’s no hard limit, but each middleware adds latency. Rails 8 ships with about 25 by default, and most production apps add 2-5 custom ones. If you’re above 10 custom middleware, reconsider whether some logic belongs in controllers instead. Profile with bin/rails middleware and measure request overhead.
Can middleware modify the request body before it reaches the controller?
Yes. You can read and replace env["rack.input"] with a new StringIO. This is how request body encryption middleware works. Be careful: env["rack.input"] must respond to read, rewind, and gets. Replace it with a StringIO wrapping your modified content.
What’s the difference between Rack middleware and Rails middleware?
Rack middleware follows the Rack specification — any Ruby object with call(env) returning [status, headers, body]. Rails middleware is just Rack middleware registered through the Rails configuration. Rails adds convenience (the config.middleware API, environment-specific stacks) but the underlying interface is pure Rack.
How do I debug middleware execution order?
Run bin/rails middleware to see the full stack. For runtime debugging, add a simple logger at the top and bottom of your call method: Rails.logger.debug "#{self.class.name} START". You’ll see the nesting pattern — requests flow down the stack and responses flow back up.
Does middleware work with Rails Action Cable and WebSockets?
Action Cable has its own middleware stack separate from the main HTTP stack. Your HTTP middleware won’t run for WebSocket connections by default. If you need middleware for WebSocket connections, configure it through config.action_cable.middleware or check how Solid Cable handles WebSocket connections.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation