35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Custom Rack Middleware in Rails 8: A Practical Guide with Real Examples

Custom Rack Middleware in Rails 8: A Practical Guide with Real Examples

Roger Heykoop
Ruby on Rails
How to write, test, and deploy custom Rack middleware in Rails 8. Covers request timing, tenant detection, request ID propagation, and the middleware stack order that trips everyone up.

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.

#rails 8 #rack #middleware #performance #ruby
R

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 Touch

Share this article

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