Understanding the Rails Middleware Stack

Understanding the Rails Middleware Stack
Understanding the Rails Middleware Stack

July 31, 2026

Middleware is one of the core building blocks of every Ruby on Rails application, yet many developers never interact with it directly. Every incoming HTTP request passes through a chain of middleware before it reaches your routes and controllers, and every response travels back through the same chain before being sent to the client.

Understanding how middleware works allows you to customize the request lifecycle, add cross-cutting concerns, and integrate application-wide behavior without modifying controllers or models.

This article explains what middleware is, how the Rails middleware stack works, how to configure it, and when to use the different middleware configuration methods.


What is Middleware?

A middleware is simply an object that sits between the web server and your Rails application.

Its job is to inspect, modify, or react to an HTTP request before it reaches your application, and optionally modify the response before it is returned to the client.

A simplified request flow looks like this:

Browser
Web Server (Puma)
Middleware A
Middleware B
Middleware C
Rails Routes
Controller
Model
Database
Response
Middleware C
Middleware B
Middleware A
Browser

Each middleware decides whether to:

  • continue processing the request,
  • modify the request,
  • modify the response,
  • or stop the request entirely.

Rack: The Foundation

Rails middleware is built on top of Rack.

A Rack middleware responds to a single method:

class MyMiddleware
def initialize(app)
@app = app
end
def call(env)
@app.call(env)
end
end

The constructor receives the next application (or middleware) in the chain.

The call method receives the Rack environment (env) and returns a Rack response:

[
status,
headers,
body
]

For example:

[
200,
{ "Content-Type" => "text/plain" },
["Hello World"]
]

A Simple Middleware

Suppose we want to measure how long every request takes.

class RequestTimer
def initialize(app)
@app = app
end
def call(env)
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
status, headers, body = @app.call(env)
elapsed =
Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
Rails.logger.info "Request took #{elapsed.round(3)} seconds"

[status, headers, body]

end end

Notice that the middleware performs work before and after the application executes.


The Middleware Stack

Rails stores middleware in a stack.

You can inspect it with:

bin/rails middleware

Example output:

use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use Rack::Runtime
use ActionDispatch::Executor
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
...

Middleware are executed from top to bottom on the request, and bottom to top on the response.


Configuring Middleware

Rails exposes the middleware stack through:

Rails.application.config.middleware

This object provides several methods for manipulating the stack.


use

Adds middleware to the end of the stack.

config.middleware.use RequestTimer

If your middleware has constructor arguments:

config.middleware.use RequestTimer, :production

Rails internally does something equivalent to:

RequestTimer.new(next_app, :production)

insert_before

Inserts middleware before another middleware.

config.middleware.insert_before(
Rack::Runtime,
RequestTimer
)

Before:

Rack::Runtime
RequestId

After:

RequestTimer
Rack::Runtime
RequestId

Use this when your middleware must execute earlier.


insert_after

Inserts middleware after another middleware.

config.middleware.insert_after(
ActionDispatch::Executor,
RequestTimer
)

Before:

Executor
RequestId

After:

Executor
RequestTimer
RequestId

This is commonly used when you want Rails to initialize its execution context first, but still execute your middleware very early.


delete

Removes middleware.

config.middleware.delete Rack::ETag

Useful when replacing default behavior.


swap

Replaces one middleware with another.

config.middleware.swap(
Rack::Runtime,
RequestTimer
)

Result:

Before
Rack::Runtime
After
RequestTimer

move_before

Moves an existing middleware.

config.middleware.move_before(
Rack::Runtime,
ActionDispatch::Executor
)

No middleware is created.

Only its position changes.


move_after

Moves an existing middleware after another.

config.middleware.move_after(
Rack::Runtime,
ActionDispatch::Executor
)

Again, only the order changes.


Constructor Arguments

All middleware configuration methods can receive constructor arguments.

Suppose your middleware is:

class RequestLogger
def initialize(app, level, destination)
@app = app
@level = level
@destination = destination
end
def call(env)
@app.call(env)
end
end

You can configure it as:

config.middleware.use(
RequestLogger,
:debug,
STDOUT
)

Rails automatically forwards the arguments:

RequestLogger.new(
next_app,
:debug,
STDOUT
)

ActionDispatch::Executor

One middleware that appears in almost every Rails application is:

ActionDispatch::Executor

This middleware wraps every request inside the Rails Executor.

Conceptually, it behaves like:

Rails.application.executor.wrap do
@app.call(env)
end

Its responsibilities include:

  • preparing the Rails execution context,
  • managing thread-local state,
  • integrating with the autoloader,
  • cleaning request-specific state,
  • ensuring each request starts from a clean environment.

You rarely interact with it directly, but many custom middleware are intentionally inserted immediately after it because it marks the beginning of Rails-managed execution.


How Middleware Executes

Imagine the stack contains:

A
B
C
Application

Execution order:

A before
B before
C before
Controller
C after
B after
A after

Each middleware surrounds the next one.

This pattern is known as the Chain of Responsibility.


Returning Early

A middleware doesn’t have to continue processing.

For example:

class MaintenanceMode
def initialize(app)
@app = app
end
def call(env)
if ENV["MAINTENANCE"] == "true"
return [
503,
{ "Content-Type" => "text/plain" },
["Service Unavailable"]
]
end
@app.call(env)
end
end

If maintenance mode is enabled, Rails never reaches the controller.


Common Middleware Use Cases

Middleware is an excellent place for functionality that applies to every request.

Examples include:

  • request logging,
  • request timing,
  • distributed tracing,
  • rate limiting,
  • custom authentication,
  • feature flags,
  • CORS handling,
  • request ID generation,
  • response header injection,
  • maintenance mode,
  • IP filtering,
  • request auditing.

If your logic should execute for every request regardless of controller, middleware is often the right place.


Choosing the Right Configuration Method

MethodWhen to UseuseAdd new middleware to the end of the stack.insert_beforeExecute before a specific middleware.insert_afterExecute after a specific middleware.deleteRemove middleware from the stack.swapReplace an existing middleware.move_beforeReorder existing middleware.move_afterReorder existing middleware.


Final Thoughts

Middleware is one of Rails’ most powerful extension points. While controllers handle application-specific behavior, middleware is designed for request-wide concerns that should execute consistently across the entire application.

Understanding how middleware is ordered, how each configuration method affects the stack, and the role of components like ActionDispatch::Executor gives you greater control over the Rails request lifecycle. Once you become comfortable with the middleware stack, you’ll find that many problems can be solved more cleanly and efficiently at this layer than inside controllers or models.

Article content

Leave a comment