Rails 8 Custom Middleware Patterns

Middleware sits between the web server and Rails application, processing every request. While Rails 8 provides excellent built-in middleware, custom middleware solves problems that application-level code handles inefficiently—request timing, tenant detection, and request enrichment all benefit from middleware treatment.

Understanding the Middleware Stack

Rails middleware follows the Rack specification: each middleware receives a request, optionally modifies it, calls the next middleware, and optionally modifies the response. The stack executes top-to-bottom on requests and bottom-to-top on responses.

To inspect the current middleware stack:

# Terminal
bin/rails middleware

# Output shows order:
# use ActionDispatch::HostAuthorization
# use Rack::Sendfile
# use ActionDispatch::Executor
# ... (many more)
# run YourApp::Application.routes

Custom middleware inserts into this stack at specific positions, giving control over when code executes relative to Rails internals.

Building Request Timing Middleware

Tracking request duration at the middleware level captures time spent across the entire stack, not just controller processing. This proves valuable for identifying slow middleware or establishing baselines.

# lib/middleware/request_timer.rb
module Middleware
  class RequestTimer
    def initialize(app, options = {})
      @app = app
      @header_name = options.fetch(:header_name, "X-Request-Time-Ms")
      @threshold_ms = options.fetch(:threshold_ms, 1000)
    end

    def call(env)
      start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      
      status, headers, response = @app.call(env)
      
      duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
      
      headers[@header_name] = duration_ms.to_s
      
      if duration_ms > @threshold_ms
        log_slow_request(env, duration_ms)
      end
      
      [status, headers, response]
    end

    private

    def log_slow_request(env, duration_ms)
      Rails.logger.warn(
        "[SlowRequest] #{env['REQUEST_METHOD']} #{env['PATH_INFO']} " \
        "took #{duration_ms}ms (threshold: #{@threshold_ms}ms)"
      )
    end
  end
end

Register the middleware in the application configuration:

# config/application.rb
module YourApp
  class Application < Rails::Application
    config.load_defaults 8.0
    
    # Insert early in stack to capture full request time
    config.middleware.insert_before 0, Middleware::RequestTimer,
      threshold_ms: 500,
      header_name: "X-Response-Time"
  end
end

Using Process.clock_gettime(Process::CLOCK_MONOTONIC) instead of Time.now prevents issues with system clock changes affecting measurements.

Tenant Detection Middleware for Multi-Tenant Apps

Multi-tenant applications need tenant identification early in the request cycle. Middleware handles this elegantly, setting up tenant context before any application code executes.

# lib/middleware/tenant_resolver.rb
module Middleware
  class TenantResolver
    TENANT_HEADER = "X-Tenant-ID".freeze
    
    def initialize(app)
      @app = app
    end

    def call(env)
      request = Rack::Request.new(env)
      
      tenant_identifier = extract_tenant(request, env)
      
      if tenant_identifier.nil?
        return [400, { "Content-Type" => "application/json" }, 
                [{ error: "Tenant identification required" }.to_json]]
      end
      
      tenant = find_tenant(tenant_identifier)
      
      if tenant.nil?
        return [404, { "Content-Type" => "application/json" },
                [{ error: "Tenant not found" }.to_json]]
      end
      
      # Store tenant in request env for downstream access
      env["app.current_tenant"] = tenant
      env["app.tenant_id"] = tenant.id
      
      @app.call(env)
    end

    private

    def extract_tenant(request, env)
      # Priority: header > subdomain > param
      env["HTTP_#{TENANT_HEADER.upcase.tr('-', '_')}"] ||
        extract_subdomain(request.host) ||
        request.params["tenant_id"]
    end

    def extract_subdomain(host)
      parts = host.split(".")
      return nil if parts.length < 3
      
      subdomain = parts.first
      return nil if %w[www app api].include?(subdomain)
      
      subdomain
    end

    def find_tenant(identifier)
      # Cache tenant lookups to avoid database hits on every request
      Rails.cache.fetch("tenant:#{identifier}", expires_in: 5.minutes) do
        Tenant.find_by(slug: identifier) || Tenant.find_by(id: identifier)
      end
    end
  end
end

Access the tenant from controllers or models through the request environment:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :set_current_tenant
  
  private
  
  def set_current_tenant
    Current.tenant = request.env["app.current_tenant"]
  end
end

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :tenant
end

Request Enrichment Middleware

Some applications need to attach computed data to requests—geographic information, feature flags, or A/B test assignments. Middleware centralizes this logic.

# lib/middleware/request_enricher.rb
module Middleware
  class RequestEnricher
    def initialize(app)
      @app = app
    end

    def call(env)
      request = Rack::Request.new(env)
      
      # Attach request metadata
      env["app.request_id"] = env["HTTP_X_REQUEST_ID"] || SecureRandom.uuid
      env["app.client_ip"] = extract_client_ip(env)
      env["app.user_agent_info"] = parse_user_agent(env["HTTP_USER_AGENT"])
      env["app.request_started_at"] = Time.current
      
      status, headers, response = @app.call(env)
      
      # Add request ID to response for tracing
      headers["X-Request-ID"] = env["app.request_id"]
      
      [status, headers, response]
    end

    private

    def extract_client_ip(env)
      # Handle proxies and load balancers
      forwarded = env["HTTP_X_FORWARDED_FOR"]
      return forwarded.split(",").first.strip if forwarded.present?
      
      env["HTTP_X_REAL_IP"] || env["REMOTE_ADDR"]
    end

    def parse_user_agent(user_agent)
      return {} if user_agent.blank?
      
      {
        mobile: user_agent.match?(/Mobile|Android|iPhone/i),
        bot: user_agent.match?(/bot|crawler|spider|crawling/i),
        browser: extract_browser(user_agent)
      }
    end

    def extract_browser(user_agent)
      case user_agent
      when /Chrome/i then "chrome"
      when /Safari/i then "safari"
      when /Firefox/i then "firefox"
      when /Edge/i then "edge"
      else "other"
      end
    end
  end
end

Middleware Ordering and Conditional Execution

Middleware position matters. Insert timing middleware early to capture full duration. Insert authentication middleware after session restoration. Rails provides several insertion methods:

# config/application.rb

# Insert at specific position (0 = first)
config.middleware.insert_before 0, Middleware::RequestTimer

# Insert relative to existing middleware
config.middleware.insert_after ActionDispatch::Executor, Middleware::TenantResolver

# Insert at end (just before routes)
config.middleware.use Middleware::RequestEnricher

# Replace existing middleware
config.middleware.swap ActionDispatch::ShowExceptions, CustomExceptionHandler

# Remove middleware entirely
config.middleware.delete Rack::Runtime

For environment-specific middleware, wrap registration in conditionals:

# config/application.rb
config.middleware.use Middleware::RequestTimer if Rails.env.production?

# Or use environment-specific files
# config/environments/production.rb
config.middleware.insert_before 0, Middleware::RequestTimer, threshold_ms: 200

Testing Custom Middleware

Test middleware in isolation using Rack::MockRequest:

# spec/middleware/request_timer_spec.rb
require "rails_helper"
require "middleware/request_timer"

RSpec.describe Middleware::RequestTimer do
  let(:app) { ->(env) { [200, {}, ["OK"]] } }
  let(:middleware) { described_class.new(app, threshold_ms: 100) }

  it "adds timing header to response" do
    status, headers, body = middleware.call(Rack::MockRequest.env_for("/"))
    
    expect(headers).to have_key("X-Request-Time-Ms")
    expect(headers["X-Request-Time-Ms"].to_f).to be > 0
  end

  it "logs slow requests" do
    slow_app = ->(env) { sleep(0.15); [200, {}, ["OK"]] }
    slow_middleware = described_class.new(slow_app, threshold_ms: 100)
    
    expect(Rails.logger).to receive(:warn).with(/SlowRequest/)
    
    slow_middleware.call(Rack::MockRequest.env_for("/slow"))
  end
end

Common Middleware Mistakes

Several patterns cause problems in production middleware:

  • Blocking I/O in middleware—Database queries or HTTP calls slow every request. Cache aggressively or move to background processing.
  • Modifying response body incorrectly—Response bodies are enumerable, not strings. Wrap modifications properly or use Rack::Response.
  • Forgetting thread safety—Instance variables persist across requests in threaded servers. Store request-specific data in env, not instance variables.
  • Swallowing exceptions—Let exceptions propagate to Rails exception handling unless intentionally catching specific errors.

Summary

Custom middleware handles cross-cutting concerns efficiently—timing, tenant resolution, and request enrichment execute once per request regardless of routing. The middleware stack provides precise control over execution order, and proper testing ensures reliability. For most applications, two to four custom middleware classes cover common needs without overcomplicating the request pipeline.

10 claps
← Back to Blog