
This post is part of the A Deep Dive Into Rack for Ruby series
- 1The Basics of Rack for Ruby
- 2Rack for Ruby: Socket Hijacking
- 3Server-sent Events and WebSockets in Rack for Ruby
Rack is the foundation for every popular Ruby web framework in existence. It standardizes an interface between a Ruby application and a web server. This mechanism allows us to pair any Rack-compliant web server (such as Puma, Unicorn, or Falcon) with any Rack-compliant web framework (like Rails, Sinatra, Roda, or Hanami).
Separating the concerns like this is immensely powerful and provides a lot of flexibility. It does, however, also come with limitations.
Rack 2 operated on the assumption that every request must provide a response and close the connection. It made no facility for persistent connections to enable pathways like WebSockets.
Developers had to make use of a hacky escape hatch to take over connections from Rack to implement WebSockets or similar persistent connections.
This all changed with Rack 3. But first, let's backtrack and take a closer look at Rack itself.
A Barebones Rack App
A basic Rack app looks like this:
class App
def call(env)
[200, { "Content-Type" => "text/plain" }, ["Hello World"]]
end
end
run App.newenv is a hash containing request-specific information such as HTTP headers. When a request is made, the call method is called, and we return an array representing the response.
The first element is the HTTP response code, in this case 200. The second element is a hash containing any Rack and HTTP response headers we wish to send. Finally, the last element is an array of strings representing the response body.
Let's organize this app into a folder and run it.
$ mkdir rack-demo
$ cd rack-demo
$ bundle init
$ bundle add rack rackup
$ touch app.rb
$ touch config.ruFill in app.rb with the following:
class App
def call(env)
[200, { "content-type" => "text/plain" }, ["Hello World"]]
end
endAnd config.ru with:
require_relative "app"
run App.newWe can run this app using the default WEBrick server by running:
$ bundle exec rackupThe server will run on port 9292. We can verify this with a curl command.
$ curl localhost:9292
Hello WorldThat's got the basic app running!
Changing Web Servers
WEBrick is a development-only server, so let's swap it out for Puma:
$ bundle add pumaNow try running rackup again. You'll see it has automatically detected Puma in the bundle and started that instead of WEBrick!
$ bundle exec rackup
Puma starting in single mode...
* Puma version: 6.4.2 (ruby 3.2.2-p53) ("The Eagle of Durango")
* Min threads: 0
* Max threads: 5
* Environment: development
* PID: 45877
* Listening on http://127.0.0.1:9292
* Listening on http://[::1]:9292
Use Ctrl-C to stopI recommend starting Puma directly instead of using rackup, as that allows us to pass configuration arguments should we want to. The -w 4 below starts Puma using 4 workers, meaning 4 instances of Puma are started up simultaneously.
$ bundle exec puma -w 4
[45968] Puma starting in cluster mode...
[45968] * Puma version: 6.4.2 (ruby 3.2.2-p53) ("The Eagle of Durango")
[45968] * Min threads: 0
[45968] * Max threads: 5
[45968] * Environment: development
[45968] * Master PID: 45968
[45968] * Workers: 4
[45968] * Restarts: (✔) hot (✔) phased
[45968] * Listening on http://0.0.0.0:9292
[45968] Use Ctrl-C to stop
[45968] - Worker 0 (PID: 45981) booted in 0.0s, phase: 0
[45968] - Worker 1 (PID: 45982) booted in 0.0s, phase: 0
[45968] - Worker 2 (PID: 45983) booted in 0.0s, phase: 0
[45968] - Worker 3 (PID: 45984) booted in 0.0s, phase: 0This basic app demonstrates the Rack interface. An incoming HTTP request is parsed into the env hash and provided to the application. The application processes the request and supplies an array as the response that the server formats and sends to the client.
Rack Compliance In Frameworks
Every compliant web framework follows the Rack spec under the hood and provides an access point to go down to this level.
In Rails, we can send a Rack response in a controller:
class HomeController
def index
self.response = [200, {}, ["I'm Home!"]]
end
endSimilarly, in Roda:
route do |r|
r.on "home" do
r.halt [200, {}, ["I'm Home!"]]
end
endEvery Rack-compliant framework will have a slightly different syntax for accomplishing this, but since they're all sending Rack responses under the hood, they will have an API for you to access that response.
You can find the full, relatively accessible Rack specification on GitHub.
Wrapping Up
As this demo shows, Rack operates under the assumption that a request comes in, is processed by a web application, and a response is sent back. Throwing persistent connections into the mix totally breaks this model, yet Rack-compliant frameworks like Rails implement WebSockets.
In the next post, part two of a three-part series, we'll cover how to take over connections from Rack so we can hold persistent connections to enable pathways such as WebSockets.
Until then, happy coding!
P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!
This post is part of the A Deep Dive Into Rack for Ruby series
- 1The Basics of Rack for Ruby
- 2Rack for Ruby: Socket Hijacking
- 3Server-sent Events and WebSockets in Rack for Ruby
Published
Wondering what you can do next?
- Subscribe to our Ruby Magic newsletter and never miss an article again.
- Start monitoring your Ruby app with AppSignal.
- Share this article on social media

Ayush Newatia
Guest author Ayush is a freelance Ruby and Rails developer. He's the author of The Rails and Hotwire Codex and part of the Bridgetown core team. He also runs a privacy focused mailing list app called Scattergun.
All articles by Ayush NewatiaBecome our next author!
AppSignal monitors your apps
AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!


