
Rack acts as a middle layer between a Ruby web server and a Ruby application. In fact, Rack is the foundation for every major Ruby web framework, such as Rails or Sinatra, which makes it a critical part of the Ruby ecosystem.
Let’s explore how Rack lets you spin up a local server in just a few lines and understand how HTTP requests are handled.
What is Rack?
Rack is a minimal interface for exposing a Ruby application to the Internet. It defines how this application should communicate with a Ruby web server (such as Puma or Unicorn).
The key features of Rack include:
-
Simple interface: A Ruby object must implement a single
call
method that receives a hash namedenv
and returns an array. Theenv
hash includes all the request data, and the array must contain three elements: an HTTP status code, headers, and the response body. -
Portability: As long as the application follows the interface, it can run on any Rack-compliant web server without any changes.
-
Ecosystem: There is a wide variety of Rack-compatible gems — for sessions, compression, logging, authentication, and more — that can be easily plugged into the same interface.
“Hello World” with Rack
Let’s create a basic example to understand how Rack works. We will use Rack::Builder’s DSL, often referred to as the rackup pattern.
-
Create a directory, for example: rack-demo:
Terminal window mkdir rack-democd rack-demo -
Generate a Gemfile and add the necessary dependencies:
Terminal window bundle initbundle add rack rackup puma -
Create the Rack configuration file config.ru with the following content:
config.ru class HelloWorlddef call(env)[200, { "content-type" => "text/plain" }, ["Hello world!"]]endendrun HelloWorld.newFirst, define the class that represents the application. Then use Rack’s
run
method, passing the instance as an argument. -
Start the web server:
Terminal window bundle exec rackupYou should see output like:
Puma starting in single mode...* Puma version: 6.6.0 ("Return to Forever")* Ruby version: ruby 3.4.3 (2025-04-14 revision d0b7e5b6a0) +PRISM [arm64-darwin24]* Min threads: 0* Max threads: 5* Environment: development* PID: 80042* Listening on http://127.0.0.1:9292* Listening on http://[::1]:9292Use Ctrl-C to stopRack is using Puma on port 9292.
When you run
rackup
, it automatically loads the config.ru file and requiresrack
, which is why therun
method from Rack::Builder is available. -
Make a request to the server using
curl
or a browser:Terminal window curl localhost:9292You will see the message rendered. No frameworks, no magic involved.
Anatomy of the call
Method
Let’s look at the structure of the call
method in more detail:
def call(env) status = 200 headers = { "content-type" => "text/plain" } body = ["Hello world!"]
[status, headers, body]end
env
: A hash that contains the request details (such asREQUEST_METHOD
,PATH_INFO
,QUERY_STRING
, etc).status
: HTTP status code (200, 404, 500, etc).headers
: A hash of key-value pairs.content-type
is required if the response includes a body. In this case, it is plain text.body
: Any object that implementseach
and returns strings. An array (which includes Enumerable) is a common choice.
Simulating Streaming
As mentioned earlier, the response body must be an object that includes Enumerable
. If the body is HTML content and you send chunks with delays, the browser will render them as they arrive, creating a streaming effect.
Try the following:
class Application def call(env) status = 200 headers = { "content-type" => "text/html" } body = Enumerator.new do |output| output << "<h1>Some title</h1>\n" sleep 1 output << "<p>First paragraph.</p>\n" sleep 1 output << "<p>Second paragraph.</p>" end
[status, headers, body] endend
run Application.new
You will see each section appear gradually.
Using a Lambda
Since call
is the only required method, you can use lambdas or procs as Rack applications. Here is a simplified example:
application = -> (env) do status = 200 headers = { "content-type" => "text/plain" } body = ["Hello world!"]
[status, headers, body]end
run application
Implementing a Simple Router
The env
hash exposes both the HTTP method and the path.
Key | What it contains |
---|---|
REQUEST_METHOD | HTTP verb (GET , POST , PUT …) |
PATH_INFO | Part of the URL after the domain |
This allows us to build a simple router:
class Application def call(env) method = env['REQUEST_METHOD'] path = env['PATH_INFO']
return method_not_allowed(method) unless method == 'GET'
case path when '/' ok("<h1>Main page</h1>") when '/about' ok("<h1>About</h1>") else not_found end end
private
def ok(body) [200, {"content-type"=>"text/html"}, [body]] end
def not_found [404, {"content-type"=>"text/plain"}, ["The page does not exist"]] end
def method_not_allowed(method) [405, {"content-type"=>"text/plain"}, ["Unsupported method: #{method}"]] endend
run Application.new
Try the following URLs in your browser:
http://localhost:9292/
http://localhost:9292/about
http://localhost:9292/foo
(should return a 404)
You can also make a POST
request using curl
:
curl -i -X POST http://localhost:9292
This will return:
HTTP/1.1 405 Method Not Allowedcontent-type: text/plainContent-Length: 24
Unsupported method: POST
Imagine rendering an ERB template for each route (as shown in this article about Embedding Ruby in HTML). You would be getting very close to how a microframework like Sinatra works.
Exercise
Create a Rack app that:
- Responds with a JSON object like
{"time":"HH:MM:SS"}
when you send aGET
request to the/time
path. - Returns a
404
error for any other path.
Hint
You will need to set the content type to application/json
and require the json
library.
Solution
require "json"
class Application def call(env) if env["REQUEST_METHOD"] == "GET" && env["PATH_INFO"] == "/time" body = { time: Time.now.strftime("%H:%M:%S") }.to_json [200, {"content-type" => "application/json"}, [body]] else [404, {"content-type"=>"text/plain"}, ["Not Found"]] end endend
run Application.new
Middleware: Rack’s Superpower
A middleware is a small Rack object that sits between the web server and your application. It receives the request, can modify it, and then optionally adjust the response as well.
Rack allows you to chain multiple middleware components together. Each one processes the request and passes it along until the final response is returned.
Here are some popular Rack middleware gems:
Gem | Purpose |
---|---|
rack-static | Serve static files |
rack-deflater | Gzip compression |
rack-session | Session management via cookies |
rack-cache | HTTP caching |
rack-cors | CORS support |
To illustrate how middleware works, I will show you examples using a few of them.
Example with rack-static
The rack-static
gem lets you serve static files.
-
Create an
images
folder in the same directory as your config.ru. -
Place an image inside, e.g., sample.jpg.
-
Update your config.ru file:
config.ru require 'rack/static'use Rack::Static, urls: ["/images"]application = -> (env) do[200, { 'content-type' => 'text/plain' }, ["Welcome to my static site!"]]endrun application -
Now try:
-
http://localhost:9292/
– You should see “Welcome to my static site!” -
http://localhost:9292/images/sample.jpg
– Your image should appear -
http://localhost:9292/images/foo.jpg
– Should show a 404: “File not found: /images/foo.jpg”
-
Example with rack-deflater
This gem compresses the response body using gzip.
-
Update your config.ru:
config.ru require 'rack/deflater'use Rack::Deflaterapplication = -> (env) dotext = "Hello from Rack. " * 50[200, { 'content-type' => 'text/plain' }, [text]]endrun application -
Make a request using
curl
:Terminal window curl -i http://localhost:9292 -
This shows the regular (uncompressed) response:
HTTP/1.1 200 OKcontent-type: text/plainvary: Accept-EncodingContent-Length: 850Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack.Notice that the response body length is 850 characters.
Rack::Deflater
does not always compress the response—compression only occurs when it is explicitly requested. -
Now request with compression enabled:
Terminal window curl -i -H "Accept-Encoding: gzip" http://localhost:9292 --output - -
You will see an output similar to this:
HTTP/1.1 200 OKcontent-type: text/plainvary: Accept-Encodingcontent-encoding: gzipTransfer-Encoding: chunkedh���H+�JL�S� _�Here, the
Content-Length
header is missing becauseRack::Deflater
works in streaming mode. This means it reads the response body, compresses it in chunks, and sends each part as it is generated. Therefore, since the final size is not known in advance, that header is removed and replaced withTransfer-Encoding: chunked
, which indicates that the response will be delivered in segments.The body is now in binary format, which is why strange characters appear. That is why we had to add
--output -
to force the output to the terminal. However, compare it to the previous response—the size has been significantly reduced!
Example with rack-session
This gem enables session storage in cookies. Here is how you can track user visits.
-
Update your config.ru:
config.ru require 'rack/session'use Rack::Session::Cookie,key: "my.session",secret: ENV.fetch("SESSION_SECRET") { "1234567890"*8 },expire_after: 86_400 # 24hclass Applicationdef call(env)req = Rack::Request.new(env)# Ignore favicon.ico requestsreturn [204, {}, []] if req.path == "/favicon.ico"session = env["rack.session"]session[:visits] = (session[:visits] || 0) + 1status = 200headers = { "content-type" => "text/html" }body = ["Visits: #{session[:visits]}"][status, headers, body]endendrun Application.new
Restart Rack and visit the page in your browser. You will see that the total number of visits is 1. If you reload the page, it will increase to 2, and so on. Open the Developer Tools, and you will find the cookie under Storage.
Creating Your Own Middleware
After reviewing examples of existing middleware, let’s now see how to create one from scratch.
Unlike a regular Rack app, a middleware must be defined as a class because it receives the next app in its constructor:
class MiddleTimer def initialize(app) = @app = app
def call(env) start = Time.now status, headers, body = @app.call(env) duration = ((Time.now - start) * 1000).round
duration_message = "\n[#{env['PATH_INFO']}] took #{duration}ms"
[status, headers, body << duration_message] endend
use MiddleTimer
run -> (env) do sleep(rand(0.1..0.5)) # Simulate some processing time
[200, { 'content-type' => 'text/plain' }, ["A random sleep was performed."]]end
- In this example, the middleware is first defined using the
MiddleTimer
class: - It defines the constructor, which stores the app that invoked it.
- It captures the current timestamp.
- It calls the
call
method of the app that invoked it. If multiple middleware components are chained, thiscall
invokes the next one. In our case, it is the app itself—the lambda defined at the end of the file. Then, it stores the response by destructuring the returned array into separate variables. This allows the middleware to modify the response.
- It calculates the duration by subtracting the initial timestamp from the current one and rounding the result to remove decimals.
- It prints the current path and the time taken to the console.
- It returns the standard Rack array, but with the calculated duration appended to the
body
. - Next, the middleware is initialized using
use
and the class name. - Finally, a Rack application is created using a lambda that simulates a random delay. In this case, the lambda is passed directly to the
run
call.
Open the browser and you will see the text along with the time it took to process the request.
Chaining Middleware
Here is how you can chain two middleware components: one that adds a prefix to the response body, and one that adds a suffix.
class PrefixingMiddleware def initialize(app) @app = app end
def call(env) status, headers, body = @app.call(env) body = ["<p>This is a prefix</p>"] + body
[status, headers, body] endend
class PostfixingMiddleware def initialize(app) @app = app end
def call(env) status, headers, body = @app.call(env) body += ["<p>This is a postfix</p>"]
[status, headers, body] endend
use PrefixingMiddlewareuse PostfixingMiddleware
run -> (env) { [200, {"content-type" => "text/html"}, ["<p>This is a Rack app</p>"]]}
When the file is executed, Rack builds the middleware chain from bottom to top:
- It starts with the
run
statement and executes the lambda. - Then, the code moves upward, instantiating
PostfixingMiddleware
and passing the Rack application (the lambda we defined) as an argument. - It continues to the next middleware, instantiating
PrefixingMiddleware
and passing it an instance ofPostfixingMiddleware
.
At this point, the chain is fully constructed and ready for execution. The request will begin at the first element in the chain (PrefixingMiddleware
), which calls its call
method with the web request (captured in the env
variable).
The first thing the call
method does is call the call
method of the next component, which is PostfixingMiddleware
. That in turn calls the application (the lambda), which simply returns the array.
Execution then moves back up the chain: the array is returned from the lambda to PostfixingMiddleware
, which modifies the body
and returns the array. This is then received by PrefixingMiddleware
, which also modifies the response and returns the final array.
This completes the chain.
The result is a response that includes the prefix, main content, and suffix in that order.
Conclusion
Rack is a minimal interface that sits between a Ruby application (which could be a framework like Ruby on Rails) and a Ruby web server. This setup allows any Rack-compliant application to work with different web servers without changing its code.
Rack defines:
- A structure for the request (a hash with many environment keys and values).
- A structure for the response (an array with status, headers, and body).
- A common interface between applications and web servers.
Rack also supports modular components called middleware, which can be chained together to build up functionality in a clean and reusable way.
Test your knowledge
-
Which single method must any object implement to be a Rack application?
-
What three elements must the call method return?
-
Which of the following objects meets Rack’s requirements for the body part of the response?
-
Because Rack only requires a
call
method, which of the following can itself be the entire Rack application?
-
In what order does Rack build the middleware chain declared in
config.ru
?