Question
Rack Middleware in Ruby: A Beginner-Friendly Explanation
Question
What is Rack middleware in Ruby, and what does the term “middleware” mean in this context? Please explain how it fits into a Ruby web application and how it handles requests and responses.
Short Answer
Rack middleware is code that sits between a web server and your Ruby application. It can inspect, modify, reject, log, or otherwise handle an HTTP request before the application receives it, and it can modify the response before it is sent back to the client. By the end of this page, you will understand the Rack request/response flow, the middleware interface, and common uses in real Ruby applications.
Concept
Rack is a Ruby interface for web applications and web servers. It gives different Ruby web frameworks—such as Rails and Sinatra—a shared way to receive HTTP requests and return HTTP responses.
A Rack application is any Ruby object that responds to call. Rack calls that method with an environment hash named env:
status, headers, body = app.call(env)
The returned value has three parts:
status: an HTTP status code, such as200or404.headers: a hash of HTTP response headers.body: an object that can be iterated to produce response content.
Middleware is a Rack application that wraps another Rack application. It receives the request first, can do work before calling the wrapped application, then receives that application's response and can do more work before returning it.
Conceptually:
Browser
↓ request
Web server
↓
Middleware A → Middleware B → Your application
↑ ↓ response
Middleware A ← Middleware B ← Your application
↑
Browser
This design matters because cross-cutting jobs—work needed by many routes or pages—can be implemented once instead of copied into every controller or endpoint. Examples include logging, authentication, sessions, compression, CORS headers, error handling, and request timing.
Mental Model
Think of a Rack application as a shop and middleware as the staff stationed around its entrance and exit.
- A customer (the HTTP request) arrives at the entrance.
- A security guard may check identification before allowing entry.
- A receptionist may write down the arrival time.
- The customer reaches the shop (your application), which prepares an order (the response).
- On the way out, a packer may add wrapping, and another staff member may record how long the visit took.
Each staff member can either:
- allow the request to continue,
- change information,
- stop it early and provide a response, or
- change the response on its way back.
A middleware stack is simply several of these wrappers placed around an application.
Syntax and Examples
A Rack middleware class usually has two methods:
initialize(app), which receives the next application in the stack.call(env), which processes the request and returns a Rack response.
class RequestLogger
def initialize(app)
@app = app
end
def call(env)
puts "Request: #{env['REQUEST_METHOD']} #{env['PATH_INFO']}"
status, headers, body = @app.call(env)
puts "Response status: #{status}"
[status, headers, body]
end
end
@app is the next middleware or the final application. Calling @app.call(env) passes the request onward.
Here is a small Rack application and the logger wrapped around it:
class HelloApp
def call(_env)
[200, { 'Content-Type' => 'text/plain' }, ['Hello from Rack!']]
end
end
app = RequestLogger.new(HelloApp.new)
When Rack calls app.call(env), RequestLogger runs first. It logs the request, calls HelloApp, logs the status returned by HelloApp, and returns the same response.
Middleware can also stop a request without calling @app. For example, this middleware blocks requests without an API key:
class RequireApiKey
def initialize(app)
@app = app
end
def call(env)
api_key = env['HTTP_X_API_KEY']
unless api_key == 'expected-key'
return [401, { 'Content-Type' => 'text/plain' }, ['Unauthorized']]
end
@app.call(env)
end
end
The early return means the request never reaches the wrapped application.
Step by Step Execution
Consider this stack:
class AddPoweredByHeader
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
headers['X-Powered-By'] = 'Rack'
[status, headers, body]
end
end
class HelloApp
def call(env)
name = env['QUERY_STRING'].split('=').last
message = name && !name.empty? ? "Hello, #{name}!" : 'Hello!'
[200, { 'Content-Type' => 'text/plain' }, [message]]
end
end
app = AddPoweredByHeader.new(HelloApp.new)
Suppose a browser requests /hello?name=Ada.
-
Rack builds the
envhash. It includes information such as the path, request method, headers, and query string. -
Rack calls
app.call(env). Here,appis anAddPoweredByHeaderobject. -
AddPoweredByHeader#callcalls@app.call(env). -
@appis theHelloAppobject, soHelloApp#callruns. -
HelloAppreadsenv['QUERY_STRING'], createsHello, Ada!, and returns:[200, { 'Content-Type' => 'text/plain' }, ['Hello, Ada!']] -
Control returns to
AddPoweredByHeader#call. -
The middleware adds
X-Powered-By: Rackto the response headers. -
It returns the final response to Rack.
-
Rack and the web server send the status, headers, and body to the browser.
The request travels inward through the stack; the response travels outward through the same stack in reverse order.
Real World Use Cases
Rack middleware is especially useful for concerns that apply across many endpoints.
- Request logging: Record HTTP methods, paths, status codes, and response times for debugging and monitoring.
- Authentication: Verify a session, bearer token, or API key before protected endpoints run.
- Authorization boundaries: Reject requests from disallowed IP addresses or require a specific role for a group of routes.
- Session handling: Load session data from a cookie before the application runs and save it into the response afterward.
- CORS: Add headers such as
Access-Control-Allow-Originso a browser frontend can call an API from another origin. - Error handling: Catch unexpected exceptions and return a controlled
500 Internal Server Errorresponse. - Security headers: Add headers such as
X-Content-Type-OptionsorContent-Security-Policyto every response. - Response compression: Compress suitable response bodies before sending them over the network.
- Maintenance mode: Return a temporary
503 Service Unavailableresponse without changing every route. - Request IDs: Attach a unique request ID so logs from different services can be connected.
Real Codebase Usage
In real applications, middleware is usually configured as a stack rather than manually instantiated in one file.
In a Rack configuration file, config.ru, use use to add middleware and run to specify the final application:
class HelloApp
def call(_env)
[200, { 'Content-Type' => 'text/plain' }, ['Hello!']]
end
end
use Rack::ContentLength
use Rack::CommonLogger
run HelloApp.new
use wraps the final application. With multiple use calls, the first middleware is the outermost layer:
Rack::ContentLength
→ Rack::CommonLogger
→ HelloApp
In Rails, middleware is configured through the application configuration. For example:
# config/application.rb
config.middleware.use RequestLogger
Developers commonly follow these patterns:
- Guard clauses: Check a condition and immediately return
401,403,404, or429instead of calling the next app. - Request context: Add values such as a request ID to
envso later middleware, controllers, and logs can access them. - Response decoration: Call the next app first, then add headers, cookies, caching directives, or timing information.
- Centralized error handling: Wrap the app with middleware that rescues expected errors and converts them to HTTP responses.
- Careful ordering: Put middleware in an order that ensures prerequisites are available. For example, session middleware must run before code that reads the session.
A simple timing middleware looks like this:
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
headers['X-Response-Time'] = format('%.3f', elapsed)
[status, headers, body]
end
end
For production middleware, be careful when changing response bodies. Some Rack bodies are streaming or require cleanup, so middleware should preserve the Rack response contract and avoid consuming a body unless it handles that responsibility correctly.
Common Mistakes
Forgetting to call the next application
If middleware should allow the request through, it must call @app.call(env).
# Broken: every request ends here, and the real app never runs.
class BrokenLogger
def initialize(app)
@app = app
end
def call(env)
puts env['PATH_INFO']
end
end
Return the next application's response instead:
@ app.call(env)
The valid Ruby code has no space after @:
@app.call(env)
Returning the wrong response shape
Rack expects a three-element array: [status, headers, body].
# Broken
'Hello'
# Valid
[200, { 'Content-Type' => 'text/plain' }, ['Hello']]
Calling @app.call(env) twice
This can execute a controller action, database write, or other side effect twice.
# Broken
@app.call(env)
status, headers, body = @app.call(env)
Call it once, save the response if you need to modify it, and return it.
Modifying headers before calling the app, then losing them
The wrapped app may return a different headers hash. To reliably add a response header, modify headers after receiving the response:
status, headers, body = @app.call(env)
headers['X-Request-ID'] = 'abc123'
[status, headers, body]
Assuming middleware order does not matter
Order changes behavior. An authentication middleware cannot use session information until session middleware has made it available. Test middleware placement and inspect the configured stack when debugging.
Treating env like a clean, application-specific hash
env contains Rack and server data with established key conventions. Avoid overwriting standard keys. If you add your own value, use a clear namespaced key:
env['my_app.request_id'] = 'abc123'
Using wall-clock time for elapsed durations
For request duration, prefer a monotonic clock:
Process.clock_gettime(Process::CLOCK_MONOTONIC)
It is not affected by system clock adjustments.
Comparisons
| Concept | What it does | Best use |
|---|---|---|
| Rack middleware | Wraps an application and handles requests and/or responses for many endpoints | Logging, sessions, authentication, headers, error handling |
| Route or controller action | Handles the business logic for one endpoint or resource | Creating an order, showing a profile, updating a record |
| Helper method | Reuses small pieces of code when explicitly called | Formatting data, parsing a value, calculating a total |
| Web server | Accepts network connections and communicates with Rack | Puma, Unicorn, Passenger, WEBrick in development contexts |
| Reverse proxy | Sits in front of the Ruby server at the network level | TLS termination, static files, load balancing; for example, Nginx |
Middleware differs from controller code because it is typically global or stack-wide. A controller decides what /orders/42 should do; middleware handles concerns that may apply before or after many routes.
Middleware can be divided by when it does work:
| Middleware style | Typical shape | Example |
|---|---|---|
| Request-only | Inspect env, then call the app | Log method and path |
| Short-circuiting | Return early without calling the app | Reject a missing API key |
| Response-only | Call app, then change response | Add a security header |
| Around middleware | Work before and after calling app | Measure request duration |
Cheat Sheet
class MyMiddleware
def initialize(app)
@app = app
end
def call(env)
# Before the application
status, headers, body = @app.call(env)
# After the application
[status, headers, body]
end
end
-
A Rack app responds to
call(env). -
A Rack response is
[status, headers, body]. -
envcontains request and server information. -
@appis the next item in the middleware stack. -
Call
@app.call(env)exactly once when forwarding a request. -
Return early to block or handle a request yourself:
return [403, { 'Content-Type' => 'text/plain' }, ['Forbidden']] -
Add middleware in
config.ruwithuse:use MyMiddleware run MyApp.new -
The first
useis the outermost middleware. -
Add request-specific custom values to
envwith a namespaced key, such asenv['my_app.request_id']. -
Modify response headers after calling the next application.
FAQ
What does middleware mean in Rack?
It means a component placed between the web server and the final application. It can process a request before the application and a response after the application.
Is Rack middleware specific to Rails?
No. Rack is a Ruby web interface used by Rails, Sinatra, and other Ruby web applications. Middleware works with any Rack-compatible application.
Does every Rack middleware need an initialize method?
Most middleware classes need initialize(app) to store the next application. A middleware can accept additional configuration arguments, but it still needs access to the wrapped app.
What is env in Rack middleware?
env is a hash containing details about the current request and server environment, such as REQUEST_METHOD, PATH_INFO, request headers, and Rack-specific values.
Can middleware stop a request?
Yes. It can return its own [status, headers, body] response without calling @app.call(env). This is common for authentication, rate limiting, and maintenance mode.
In what order does Rack middleware run?
Requests enter from the outermost middleware toward the app. Responses return from the app back through those middleware layers in reverse order.
Where do I add middleware in a Rails application?
Rails applications configure it through config.middleware, commonly in config/application.rb or an environment configuration file.
Why should I not put all logic in middleware?
Middleware is best for reusable HTTP-level concerns. Endpoint-specific business rules usually belong in routes, controllers, service objects, or domain code.
Mini Project
Description
Build a small Rack application protected by middleware. The middleware will assign every request a unique ID, log the request, and add that ID to the response headers. Request IDs are useful when matching a client-reported problem to server logs.
Goal
Create a Rack middleware stack that logs each request and returns an X-Request-ID header with every response.
Requirements
Create a Rack application that returns a plain-text welcome message.
Create middleware that generates a unique request ID.
Store the request ID in env using a namespaced key.
Log the request method, path, and request ID.
Add the same request ID to the response as X-Request-ID.
Keep learning
Related questions
Add a Default Value to an Existing Column in a Rails Migration
Learn how to add or change a default value on an existing database column using Rails Active Record migrations safely.
Calling a Class Method from an Instance in Ruby
Learn how to call a class method from an instance in Ruby using self.class, with examples, pitfalls, and practical usage patterns.
Calling an Overridden Monkey-Patched Method in Ruby
Learn how to call the original method when monkey patching in Ruby, including alias_method patterns, examples, pitfalls, and practical usage.