
Puma, Falcon or Pitchfork? Choosing a Rails App Server in 2026
TL;DR
Puma stays the right default for most Rails apps and gained useful knobs in 8.0. Pitchfork wins on memory and tail latency for large CPU-heavy monoliths with a team that can own fork safety. Falcon is the pick for LLM streaming, SSE and WebSocket-heavy services. Choose by workload, measure p99 and RSS.
Threads, fibers or forked processes? A practical comparison of Puma 8, Falcon 0.57 and Pitchfork 0.18 for Rails on Ruby 4, with configs and a decision by scenario.
Also available in Português (Brasil)
Every Rails app ships with Puma, and for most of them that is the end of the story. But 2026 is the first year the question deserves a real answer. Ruby 4.0 is in production, YJIT is the default JIT, Shopify has been running its monolith on Pitchfork for three years, and Falcon reached a point where falcon-rails is a one-line install. Three servers, three concurrency models, and the right pick depends on what your app actually does with a CPU core.
This post closes the infrastructure trilogy we started with Solid Queue vs Sidekiq and the seven layers of cache. Jobs, cache, and now the process that serves the request.
The three concurrency models in one paragraph
Puma runs N worker processes, each with a pool of M threads. Threads share the GVL, so inside one process only one thread runs Ruby code at a time, but threads waiting on I/O release the lock. Pitchfork runs N single-threaded processes forked from a warm "mold", with no threads at all. Falcon runs N processes, each handling thousands of requests on lightweight fibers scheduled by the async reactor; a fiber blocked on I/O yields to the next one without an OS thread switch.
The GVL is still there in Ruby 4.0. Ractors got real improvements (Ractor::Port, less shared internal state, better cache behavior), but Rails does not serve requests from Ractors and none of the three servers use them. So for a web request, parallelism still means processes, and concurrency means threads or fibers. That constraint is what makes the choice interesting.
What changed in 2026
Puma 8.0 shipped in April 2026, with 8.0.2 in May closing two PROXY protocol issues. The notable new pieces for capacity planning are workers :auto (from 7.2), a max_io_threads setting with env["puma.mark_as_io_bound"] so streaming and long-poll requests can exceed the regular thread pool, and runtime thread-pool resizing via update_thread_pool_min_max. The one breaking change: production now binds to :: (IPv6) when a non-loopback IPv6 interface exists, falling back to 0.0.0.0. If your container network is IPv4-only this is invisible; if it is dual-stack, check your health checks.
Pitchfork 0.18.2 (January 2026) is mature and deliberately boring. Jean Boussier's own writeup is explicit that he does not want "Pitchfork hype": reforking is a tool for teams that can debug fork-safety bugs, and the headline production numbers at Shopify were around 30% less memory and about 9% lower latency compared to Unicorn.
Falcon 0.57.0 (August 2026) requires Ruby 3.3+, added utilization metrics, a CompositeServer for hosting several server instances in one worker, and ships a Railtie that flips config.active_support.isolation_level to :fiber automatically. It speaks HTTP/1.1 and HTTP/2 natively, including TLS, without a proxy in front.
Puma: the default, now with knobs that matter
Rails 8 generates a config/puma.rb with RAILS_MAX_THREADS defaulting to 3, WEB_CONCURRENCY driving workers, and Thruster in front for HTTP/2, compression, and X-Sendfile. For the overwhelming majority of CRUD apps this is correct and you should leave it alone.
The 2026-era config worth copying is the one that uses fork_worker to get Pitchfork-style reforking without changing servers:
# config/puma.rb
workers ENV.fetch("WEB_CONCURRENCY") { :auto }
max_threads = Integer(ENV.fetch("RAILS_MAX_THREADS", 3))
threads max_threads, max_threads
# Worker 0 boots the app; other workers are forked from it.
# After 1000 requests on worker 0, Puma reforks the fleet
# from the warmed-up worker. Do not combine with preload_app!.
fork_worker 1000
# Let LLM streaming or SSE exceed the regular pool.
max_io_threads 8
port ENV.fetch("PORT", 3000)
plugin :tmp_restart
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
Two gotchas. fork_worker and preload_app! are mutually exclusive, because worker 0 is the preloader. And max_io_threads only helps if you mark the request: in a controller, request.env["puma.mark_as_io_bound"] = true before you start streaming.
Threads are also where Puma hurts. Three threads sharing a GVL on a CPU-bound action means tail latency climbs under load, and the only cure is more processes, which means more memory. That is the exact problem the next server was built for.
Pitchfork: Unicorn with a warm heart
Pitchfork is a fork of Unicorn with one big idea. Instead of forking workers from a freshly booted master and watching copy-on-write pages get copied as each worker warms up (inline caches, YJIT code, memoized constants), it promotes a worker that has already served traffic into a new "mold" and reforks the fleet from that. Shared pages stay shared, and replacing a crashed worker gives you a warm process rather than a cold one.
# config/pitchfork.rb
worker_processes Integer(ENV.fetch("WEB_CONCURRENCY", 8))
listen ENV.fetch("PORT", 3000)
timeout 30
# Refork after 50 requests, then 100, then every 1000.
refork_after [50, 100, 1000]
after_mold_fork do |server, mold|
# Warm what you can before workers are forked from this mold.
Rails.application.eager_load!
end
after_worker_fork do |server, worker|
# Rails 7.1+ reconnects Active Record automatically via ForkTracker.
end
bundle exec pitchfork -c config/pitchfork.rb -E production
The constraints are real. Reforking depends on PR_SET_CHILD_SUBREAPER, so it is Linux-only; macOS development runs without it. Each worker is single-threaded, so an app with slow external HTTP calls will need more processes than it would need Puma threads. And anything that opens a connection, spawns a thread, or caches a file descriptor at boot has to be fork-safe, because the process that holds it will be forked again mid-life, not just at startup. Boussier's guidance is blunt: if you are a large Unicorn shop, migrate; if you want a modernized Unicorn without reforking, that is also fine; otherwise you probably do not need it.
On memory the gap is dramatic in the small case. His early micro-benchmark had Puma at 2 workers and 2 threads using about 600 MiB where Pitchfork with 4 processes used about 320 MiB. That is a lot of headroom on a Kamal VPS.
Falcon: fibers, HTTP/2, and the streaming use case
Falcon takes the opposite bet. One process, many fibers, and every blocking I/O call inside Ruby's fiber scheduler becomes a yield point. A request waiting on Postgres, Redis, or an upstream API costs almost nothing while it waits. That is why it is the natural server for LLM streaming with RubyLLM, Server-Sent Events, and WebSocket-heavy apps: a thousand open streams are a thousand parked fibers, not a thousand threads or processes.
# Gemfile
gem "falcon-rails"
#!/usr/bin/env -S falcon host
# falcon.rb
require "falcon/environment/rack"
service "myapp" do
include Falcon::Environment::Rack
preload "preload.rb"
count Integer(ENV.fetch("WEB_CONCURRENCY", 4))
endpoint Async::HTTP::Endpoint
.parse("http://0.0.0.0:#{ENV.fetch('PORT', 3000)}")
.with(protocol: Async::HTTP::Protocol::HTTP11)
end
# preload.rb
require_relative "config/environment"
The tradeoffs are about the ecosystem rather than the server. Every gem in the request path must do I/O through Ruby's IO layer or a scheduler-aware driver. pg supports the fiber scheduler; check whichever MySQL driver you use before assuming. A C extension that blocks in native code stalls the whole reactor, which is worse than stalling one Puma thread. Your Active Record pool must be sized for concurrent fibers, not threads, so pool in database.yml needs to go up, and anything that assumed Thread.current as request-local state needs to respect the :fiber isolation level Falcon's Railtie sets. CPU-bound actions gain nothing: the GVL is still one lock per process, fibers or not.
Comparison
| Puma 8.0 | Pitchfork 0.18 | Falcon 0.57 | |
|---|---|---|---|
| Unit of concurrency | Threads in processes | Processes only | Fibers in processes |
| Parallel CPU | Per process | Per process | Per process |
| I/O-bound throughput | Good | Needs more processes | Excellent |
| Memory per unit | Medium | Lowest with reforking | Lowest per connection |
| Tail latency under CPU load | Degrades with threads | Best (no GVL contention) | Degrades like Puma |
| Streaming / SSE / WebSocket | OK with max_io_threads | Poor | Native |
| HTTP/2 | Via Thruster | Via proxy | Native, with TLS |
| Fork-safety burden | Moderate (fork_worker) | High (reforking) | Low |
| Fiber-safety burden | None | None | High |
| Platforms | All | Linux for reforking | All |
| Rails default | Yes | No | No |
Measure before you switch
Every server migration I have seen go wrong started with a benchmark of hello-world endpoints. Measure your app, under your traffic shape, with three numbers: p99 latency, RSS per worker after an hour of traffic, and the fraction of request time spent waiting on the GVL.
The last one is the decider between Puma and Pitchfork, and the gvl_timing instrumentation that landed in Ruby 3.2 makes it cheap to collect. Wrap it in a Rack middleware or use the gvltools gem and ship the number to your APM as a custom metric:
# config/initializers/gvl_metrics.rb
require "gvltools"
GVLTools::LocalTimer.enable
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*, payload|
waited_ms = GVLTools::LocalTimer.monotonic_time / 1_000_000.0
GVLTools::LocalTimer.reset
StatsD.histogram("rails.gvl_wait_ms", waited_ms, tags: ["action:#{payload[:action]}"])
end
If GVL wait is a meaningful slice of p99 on your hot actions, threads are fighting each other and Pitchfork (or fewer Puma threads with more workers) will help. If it is near zero and you still have latency, the time is in I/O, and Falcon or more Puma threads is the direction. If RSS is the complaint and GVL wait is low, start with fork_worker and jemalloc before touching the server at all.
For the load test itself, replay production traffic shape rather than a synthetic mix. A WEB_CONCURRENCY sweep on a staging box with the same instance type is enough: 2, 4, 8 processes, each with the thread or fiber count you intend to run, and record the three numbers per combination. One afternoon of this produces a better decision than any benchmark in a blog post, this one included.
Decision by scenario
Standard CRUD app, one Kamal server, team under ten people. Puma, as generated, with Thruster in front. Add fork_worker once you have real traffic. Nothing else is worth the operational novelty.
API or app with LLM streaming, SSE, or thousands of idle WebSocket connections. Falcon for that service. If the rest of the app is conventional, split: Puma for the main app, a small Falcon service for the streaming endpoints behind the same proxy. Do not move a large legacy monolith to Falcon in one go; audit the gems first.
Large monolith, memory-bound, CPU-heavy actions, platform team on call. Pitchfork. The memory and tail-latency wins are the ones Shopify measured, and your team can own the fork-safety audit. Start without reforking, measure, then turn on refork_after.
Already on Puma and hitting memory limits. Before switching, try fork_worker, jemalloc, and RUBY_GC_HEAP_* tuning. Those recover most of the Pitchfork memory story with zero migration.
Ruby 4.1 and ZJIT. None of this changes the server choice. ZJIT is still experimental in 4.0 and YJIT is the production JIT. Any JIT favors long-lived warm processes, which is the reforking argument again; it does not favor one server's concurrency model over another.
The short version
Puma is the right default and got better in 2026. Pitchfork is the right tool for a specific kind of pain, and its author will tell you not to use it otherwise. Falcon is the right tool for a specific kind of workload, and it is finally easy to put next to a Rails app rather than instead of one. Pick by workload, measure p99 and RSS, and keep Thruster or a proxy in front whichever way you go.
Sources
- Puma on RubyGems (8.0.2) rubygems.org
- Puma changelog (History.md) github.com
- Pitchfork on RubyGems (0.18.2) rubygems.org
- Pitchfork: Reforking documentation github.com
- Jean Boussier: The Pitchfork Story byroot.github.io
- Falcon on RubyGems (0.57.0) rubygems.org
- Falcon: Rails Integration guide socketry.github.io
- gvltools gem (GVL instrumentation) github.com
- Ruby 4.0.0 Released www.ruby-lang.org
- Rails 8: No PaaS Required (Thruster and Kamal 2) rubyonrails.org
FAQ
Is Puma still the best choice for a new Rails 8 app?
Yes. The generated config with Thruster in front covers the common case, and Puma 8 added workers :auto, max_io_threads and runtime pool resizing. Switch only when you can name the metric the other server will fix.
Does Ruby 4.0 remove the GVL?
No. Ruby 4.0 improved Ractors, but Rails does not serve requests from Ractors and none of the three servers use them. Inside one process only one thread runs Ruby at a time, so parallelism still comes from processes.
Can I get Pitchfork's reforking benefit without leaving Puma?
Mostly. Puma's fork_worker mode boots the app in worker 0 and reforks the other workers from it after a request count. It is not compatible with preload_app!, and it carries the same fork-safety requirements, but it avoids a server migration.
What breaks when I move a Rails app to Falcon?
Anything that blocks in native code without going through Ruby's IO layer, gems that are not scheduler-aware, an Active Record pool sized for threads instead of fibers, and code that stored request state in Thread.current. Audit the request path before switching.
Does Pitchfork work on macOS?
It runs, but reforking does not. Reforking depends on PR_SET_CHILD_SUBREAPER, which is Linux-only, so development on macOS behaves like a plain preforking server and production on Linux gets the memory benefit.
Comments
Sign in with GitHub to comment.
No comments yet.