puma-plus
A Ruby web server with a Go front end, built around one idea: request queue time is the number that matters, so measure it exactly and use it to size the backend automatically.
Go owns HTTP/1.1, HTTP/2 and HTTP/3. A Ruby gem runs the Rack app. Between them is a small binary protocol over unix sockets.
gem install puma-plusIt runs Rails and Sinatra, conforms to Rack 3, and is configured by a Ruby file that reads like a puma config. It is still experimental — a spike that grew up enough to install, not a product. Nothing here has run in production, the version is 0.1.0, and the interfaces may change.
┌─────────────────────────────┐
h1 / h2 / h2c / h3 ──────────▶ │ Go: parse, queue, measure │
│ dispatch, control │
└──────────┬──────────────────┘
│ binary frames over unix sockets
┌──────────▼──────────────────┐
│ Ruby: shepherd → workers │
│ each thread = one conn │
└─────────────────────────────┘
Why
Puma cannot see its own queue
Queue time — how long a request sat runnable but unserved — is the single best
signal of whether a Ruby app is under-provisioned. Puma does not measure it.
X-Request-Start appears in puma's repository only in documentation; the actual
measurement is delegated to APM middleware, which computes it from a header a
load balancer set, at millisecond granularity, across a clock boundary.
The only per-request timing puma emits is env['puma.request_body_wait']
(lib/puma/client.rb:428,746) — time spent waiting for the client to send the
body. Its entire stats surface (lib/puma/thread_pool.rb:143) is backlog,
backlog_max, running, pool_capacity, busy_threads, requests_count,
reactor_max. No latency. No percentiles. Nothing per-request.
This is not an oversight, it is structural. Puma's queue is smeared across three
places: the kernel accept backlog (invisible), each worker's @todo array, and
the reactor. No single component knows the depth, and what does get reported is
sampled worker→master every worker_check_interval (default 5 seconds) over
a hand-rolled pipe protocol.
So puma's thread pool scales reactively and blindly: threads spawn only on
enqueue when @waiting < @todo.size (thread_pool.rb:347), shrink on a 30-second
trim timer, and know nothing about latency.
puma-dev has no admission control at all
The Go-in-front idea comes from puma-dev, which proves the architecture works but
punts on everything this project is about. It has no queue depth, no
backpressure, and no per-app connection cap. Its readiness check dials the app's
socket every 250ms, with a comment in the source calling it "a poor substitute
for getting an actual readiness signal" (dev/app.go:336-361). Its reverse
proxy sets FlushInterval: 1 * time.Second (dev/http.go:63), which batches
server-sent events into one-second hiccups.
The fix is architectural, not incremental
Ruby worker threads dial in to Go rather than Go connecting to them. An idle connection is a unit of capacity. Everything follows from that inversion:
| puma | puma-plus | |
|---|---|---|
| queue depth | smeared across 3 places, sampled every 5s | one integer, exact, instant |
| queue time | not measured | measured in Go, 2 clock reads/request |
| readiness | — (puma-dev dial-polls every 250ms) | a connection appearing |
| worker death detected in | up to 60s (worker_timeout) |
~2s (1 Hz heartbeat), or instantly on EOF |
| scaling input | thread pool pressure | measured queue-time p95 |
| protocols | HTTP/1.1 | h1, h2c, h2, h3 |
Because the queue lives in exactly one Go data structure, we own both sides of
Little's Law: L is an integer we maintain, W is measured per request. That
makes W_pred = (L+1)·S̄/c — the expected wait of the request arriving now —
computable. Measured percentiles are lagging indicators; a request must suffer
the queue before it can report it.
Measurements
All numbers from an ARM64 Lima VM, 12 CPUs, Ruby 3.2.3 without YJIT, over loopback. Servers pinned to CPUs 0–7, load generator to 8–11. Compared against puma 8.0.2 and falcon 0.54.3 running the same Rack app.
These are same-machine relative comparisons. They are not absolute throughput and do not transfer to the x86 hardware this would really run on.
On the load generator. wrk, hey and ab are closed-loop: each virtual user waits for its response before issuing the next request, so when the server slows down the generator slows with it and the queue never grows the way it does under real traffic. That is coordinated omission, and it makes those tools structurally incapable of measuring this. The suite ships its own open-loop generator that schedules arrivals from an independent clock and measures latency from each request's intended start time. See
bench/README.md.
The headline: a 12× step
The workload. Every server runs the same Rack app, which picks its behaviour
from the request path. /mix/<cpu_ms>/<sleep_ms> burns that many milliseconds of
CPU and then sleeps that many more, so /mix/5/20 is 5ms of computation followed
by 20ms of waiting — roughly the shape of a Rails request that renders a little
and waits on a database. The CPU portion is calibrated against
CLOCK_THREAD_CPUTIME_ID rather than a fixed iteration count, so "5ms of CPU"
means the same thing on ARM and on x86.
25ms of service time means one thread can serve ~40 requests/second, so puma
-w2 -t5 (10 threads) tops out near 400 rps.
The traffic. Offered load steps 60 → 700 → 60 rps, holding each for 30s, 60s and 30s. The middle phase is deliberately ~1.75× what an average-sized server can handle: an earlier version of this scenario stepped to 260 rps, stayed inside capacity, and every server scored identically because nothing ever queued.
Requests arrive at random times rather than on a metronome — a Poisson arrival process:
evenly spaced · · · · · · · · · · · ·
Poisson ·· · ···· · · ··· · ·· ·
Both average 700 per second. But real users don't take turns, so they clump — and a clump is what makes a queue. Spacing requests evenly would smooth away the exact bursts we are trying to measure.
| server | rps | p50 | p95 | p99 | excess latency¹ | mean RSS | mean procs |
|---|---|---|---|---|---|---|---|
puma -w2 -t5 (sized for average) |
315 | 25.32s | 48.64s | 50.69s | 1.1 Gms | 92MB | 3.0 |
puma -w6 -t5 (sized for peak) |
382 | 27.6ms | 35ms | 38.1ms | 0 | 180MB | 7.0 |
falcon -n2
|
293 | 17.93s | 35.94s | 37.71s | 749.7Mms | 116MB | 3.0 |
| puma-plus static 2×5 | 316 | 24.88s | 47.88s | 49.91s | 1.1Gms | 98MB | 4.0 |
| puma-plus autoscale 1..6 | 382 | 30.2ms | 2.6s | 2.76s | 20.3Mms | 130MB | 6.2 |
¹ total latency above the 150ms SLO, summed over every request. 1.1 Gms is not a typo: ~45,000 requests each waiting ~25 seconds over a 150ms target.
These numbers predate the settled-gate fix described in
docs/RACTORS.md, which changed how quickly the controller
ramps. They are reported as measured, on Ruby 3.2.3; the autoscaling row in
particular would move on a rerun. The shape of the result — autoscaling matching
peak provisioning on throughput and p50, losing on p99 — has held across every
rerun since.
This table is a single run per server, unlike the steady-state and overhead tables below which are medians of three. The effects here are large enough (seconds versus milliseconds) that run-to-run variance cannot account for them, but a single rep is weaker evidence than three and should be read that way.
Three readings, including the one that does not flatter us:
Autoscaling matched peak provisioning's throughput and p50, starting from one worker. 382 rps either way, p50 30.2ms vs 27.6ms, at 28% less average memory (130MB vs 180MB). Against the configuration people actually deploy — sized for the average — it delivered 21% more throughput and cut excess latency 56×.
Peak provisioning still wins p99 decisively: 38ms vs 2.76s. Scaling 1→6 workers takes seconds, and every request arriving during the ramp queues.
The obvious explanation is fork dead time, and that turned out to be only part of
it. Replacing fork with Ractor.new — a ~125× faster actuator — barely moved
p99 at all, because the controller adds one unit per decision and each
decision costs a confirmation window no actuator can shorten. Fork dead time is
real and secondary: with the ramp measured properly, forking costs 2.78s p99
where Ractors cost 1.16s. Most of the remainder is the ramp policy itself.
That investigation is written up in docs/RACTORS.md,
including the controller bug it uncovered — readiness was inferred from a 2s
timer rather than from the connection that already signals it — and the mistake
in reasoning it produced. If your load is predictable, provision for it.
puma-plus static ties puma at the same shape — 316 vs 315 rps, 24.88s vs 25.32s p50. The Go↔Ruby hop costs nothing measurable under load.
Watching it scale
The table above is an average over the whole run, which hides the interesting
part. Here is the same autoscaling run second by second (bench report -timeline):
t rps p50 p99 procs queue_p95
39s 59 28.7ms 37.7ms 3 0.0
40s 699 830ms 1.23s 4 604.0
44s 660 2.73s 2.83s 5 2415.9
48s 726 2.49s 2.53s 6 2415.9
52s 700 1.58s 1.73s 7 1208.0
56s 705 214.6ms 410.2ms 8 1006.6
Reading the columns:
- rps — requests completed that second, so you can see the load step land.
- p50 / p99 — what clients experienced, measured from intended arrival.
-
procs — the whole process tree: the Go server, the Ruby shepherd, and one
per worker. So
3is one worker, and8is six. -
queue_p95 — milliseconds requests spent waiting for a free worker, read
from puma-plus's own
/stats. This is the number the controller steers on.
What happens:
t=39s. One worker, 59 rps, queue_p95 is flat zero. There is always a free
thread, so nothing waits.
t=40s. Load jumps to ~700 rps against a server sized for 60. Capacity is
instantly ~12× short. Queue time goes from 0 to 604ms in a single second, and
p50 follows it to 830ms. The controller sees the breach and forks — procs goes
3 → 4.
t=40→48s. Workers are added one at a time, not all at once: a fork must
finish booting before the next is allowed (the "settled gate"). Meanwhile the
backlog is still growing, and queue_p95 peaks at 2.4 seconds.
Here is the part worth understanding. Latency keeps climbing while capacity is being added. That is not the controller failing — it is the backlog that built during the ramp having to be worked off. New workers first drain the queue that already exists before newly arriving requests feel any relief. The gap between "capacity added" and "latency recovered" is fork dead time, and it is the entire reason peak provisioning still wins p99 in the table above.
t=52→56s. Capacity now exceeds the arrival rate, so the backlog drains.
queue_p95 falls 2415 → 1208 → 1006ms, and p50 follows it down from 2.5s to
215ms.
Total: full recovery from a 12× step in about 16 seconds, with zero shed requests and zero errors — every one of those 45,853 requests was eventually served.
For contrast, here is average-sized puma under exactly the same load:
t rps p50 p99 procs queue_p95
30s 73 27ms 32.3ms 3 (n/a)
42s 704 2.05s 2.78s 3 (n/a) ← step lands
54s 737 12.35s 12.79s 3 (n/a)
66s 717 22.54s 22.99s 3 (n/a)
78s 737 32.5s 32.96s 3 (n/a)
90s 717 43.38s 43.82s 3 (n/a) ← still climbing
102s 69 49.11s 49.49s 3 (n/a) ← load drops, backlog remains
126s 59 28.64s 29.09s 3 (n/a) ← run ends, still draining
Latency grows linearly and without bound — roughly 10 seconds of added p50 for every 12 seconds of overload. That straight line is the signature of a queue with no admission control: nothing sheds, nothing scales, so the backlog simply accumulates. When the load finally drops at t=90s, latency keeps rising for another 12 seconds before draining begins, and the run ends with p50 still at 28.6 seconds. Those requests all eventually returned 200, long after any real user had given up.
The queue_p95 column is (n/a) for a reason worth sitting with: puma cannot
report it. The harness polls each server's own stats endpoint, and puma has no
latency or queue-time metric to give. Everything in the left-hand columns is
measured by the client from outside. The one number that would have told an
operator what was happening — and told a controller what to do about it — does
not exist inside puma.
Steady load: where autoscaling should show nothing
The same /mix/5/20 request at a flat 120 rps against servers that are correctly
sized for it. Median of 3 reps, 0.0% spread.
| server | rps | p50 | p95 | p99 | mean RSS | mean procs |
|---|---|---|---|---|---|---|
puma -w2 -t5
|
119 | 27.4ms | 33.5ms | 36.9ms | 76MB | 3.0 |
falcon -n2
|
119 | 27.3ms | 33.8ms | 37.3ms | 101MB | 3.0 |
| puma-plus static 2×5 | 119 | 27.4ms | 33.4ms | 36.7ms | 68MB | 4.0 |
| puma-plus autoscale | 119 | 30.7ms | 45.2ms | 54.0ms | 51MB | 3.0 |
Autoscaling is slightly worse here, and that is the expected answer. All four delivered the offered rate exactly. The three static configurations are indistinguishable on latency. Autoscaling costs ~3ms of p50 and ~17ms of p99 because it settles at fewer workers and therefore runs closer to the edge — in exchange for 33% less memory than static puma.
That is the honest shape of the trade: when you have sized your server correctly, this buys you nothing and costs you a little tail latency. It earns its keep when the load moves or the operator guessed wrong.
The overhead floor: what the Go hop costs
puma-plus does something puma does not: it moves every request across a process
boundary. /hello returns a fixed string, so almost all of what is measured is
that overhead. 805 rps offered, median of 3 reps, spread 0.0%.
| server | rps | p50 | p95 | p99 | mean RSS |
|---|---|---|---|---|---|
puma -w4 -t5
|
805 | 753µs | 1.4ms | 1.6ms | 128MB |
falcon -n4
|
805 | 868µs | 1.7ms | 2.2ms | 163MB |
| puma-plus 4×5 | 805 | 756µs | 1.4ms | 1.6ms | 105MB |
puma and puma-plus differ by 3 microseconds, which this harness cannot resolve. Its floor is ~1.5ms, measured against a server that does nothing at all. The honest statement is that the Go↔Ruby hop is not measurable here — not that it is free, and certainly not that puma-plus is faster. A machine with finer timing resolution might well find puma ahead.
The memory column is above the noise and is worth a note: puma-plus used 105MB across more processes (6 vs 5 — a Go process, a shepherd, and four workers) than puma's 128MB. The Ruby workers are thinner, because HTTP parsing, the reactor and the connection machinery all live in Go instead. That is a real consequence of the architecture rather than a tuning artifact, though on a trivial app it is also the least interesting thing about it.
A note on comparing against falcon
Falcon uses fibers, not threads, and that makes "equivalent configuration" genuinely hard to define. Two things had to be checked before any falcon number above could be trusted.
Does the benchmark app block falcon's reactor? Ruby 3.x routes Kernel#sleep
through Fiber::Scheduler#kernel_sleep when a scheduler is installed, and Async
installs one — so sleep should yield rather than block. "Should" is not good
enough: if it did not, falcon would be serializing every request and every number
here would be unfair to it. Measured directly (bench/checks/fiber-scheduler.sh),
20 concurrent requests each sleeping 500ms against a single server process:
| elapsed | effective concurrency | ||
|---|---|---|---|
puma -w1 -t1 — control |
10.06s | 1.0 / 20 | serialized, as it must be |
puma -w1 -t20
|
0.51s | 19.5 / 20 | concurrent |
falcon --count 1 |
0.51s | 19.6 / 20 | concurrent |
| puma-plus 1 × 20 threads | 0.51s | 19.5 / 20 | concurrent |
The single-thread control serializing is what makes this meaningful — the test
could have detected the failure and did not. sleep yields properly, and the
Rack app needs nothing special for falcon.
But the concurrency budgets are not comparable, and this favours falcon.
Falcon got 20-way concurrency out of one process; puma needed 20 threads for
the same. --count sets processes only — there is no fiber limit to match
against a thread count. So in the tables above, falcon -n2 and puma -w2 -t5
have CPU parity (two processes each, so two cores' worth of GVL) but falcon has
unbounded request concurrency where puma has exactly ten threads.
That asymmetry is visible in the step results: falcon reached a better p50 than
average-sized puma (17.93s vs 25.32s) precisely because nothing was queued behind
a thread limit. What it could not do was convert that into throughput — /mix
has a 5ms CPU component, and burn_cpu is a tight loop with no yield points, so
two falcon processes are capped at two cores just as two puma workers are. It
also completed 4,175 fewer requests than the others, having hit the scenario's
20-second client timeout.
Matching falcon on processes is the fairest single knob available, but it is not equivalence, and the tables should be read with that in mind.
Protocols
puma-plus only — puma has no HTTP/2 or HTTP/3 at all, and falcon has no HTTP/3.
| protocol | p50 | p95 | p99 | p99.9 |
|---|---|---|---|---|
| h1 | 28.6ms | 35.9ms | 39.4ms | 44.1ms |
| h1 over TLS | 28.7ms | 36.0ms | 40.2ms | 43.3ms |
| h2c | 28.7ms | 35.8ms | 38.9ms | 43.3ms |
| h2 | 28.7ms | 36.4ms | 40.1ms | 43.5ms |
| h3 | 28.7ms | 36.5ms | 40.1ms | 49.5ms |
These rows are a capability result, not a performance one. All five land
within noise, which is exactly what should happen: everything h2 and h3 buy —
fewer round trips, 0-RTT resumption, no head-of-line blocking — needs latency to
be visible, and loopback has none. h3's slightly worse p99.9 is QUIC's userspace
packet handling costing something for no compensating benefit at zero RTT. A fair
protocol comparison needs synthetic RTT (tc netem in a netns), which this VM
cannot do unprivileged. Do not quote these as evidence about h3.
What they do show: all three protocols produce byte-identical Rack envs
apart from SERVER_PROTOCOL, verified by
TestProtocolsProduceIdenticalEnv. The Ruby side contains no protocol-specific
code whatsoever.
The measurement itself
Queue time is only useful if it is correct. 40 concurrent requests against 4 threads with a 100ms service time, on a cold server:
queue p50 369ms (expected ~450: 10 waves of 4 at 100ms)
queue p95 872ms
service 109.1ms (app sleeps 100ms)
TTFB p50 503.3ms vs queue + service = 478.2ms
Three independently measured intervals that reconcile. Under light load, queue p99 is 1.9 microseconds.
Client slowness is kept out of the signal. A 2MB body dribbled over 2 seconds:
body read 997.3ms ← charged here
service 15.7ms ← after subtracting body read
queue 0.0ms ← uncontaminated
Cost on the hot path: two time.Now() calls (vDSO, no syscall) and one atomic
histogram increment, measured at 2.0ns uncontended and 182ns with all 12
cores recording in a tight loop. Zero allocations.
What is deliberately not claimed
-
Sub-millisecond overhead. The harness's own resolution floor is ~1.5ms on
this machine, measured against a server that does nothing. An early
/hellorun showed puma, falcon and puma-plus all at "p50 1.2ms" — that was the harness measuring itself. Three servers reporting the same number there are indistinguishable, not equal. - Beating a correctly-sized static server. On steady load a correctly sized puma matches puma-plus exactly. Autoscaling wins when the operator guessed wrong or the load moves — which is most production, but it is not a speed result.
- Helping CPU-bound work past saturation. Twelve cores is twelve cores. Past hardware saturation, capacity cannot fix queue time and a controller that keeps forking is actively harmful. What differs there is bounded brownout via shedding versus unbounded collapse.
- Anything about HTTP/3 performance. See above.
-
Thread scaling. Implemented behind a flag and off by default. Under the GVL,
adding a thread to CPU-bound work makes queue time worse, so the gain sign is
unknown — which is also why the controller is tiered thresholds with hysteresis
rather than a PID. GVL wait is now measured directly, through
rb_internal_thread_add_event_hookin an optional C extension, so the controller can tell "the GVL is the bottleneck" from "more threads would help" rather than inferring it. Without the extension it reports unknown rather than zero: an absent measurement must not read as "no contention", or the controller confidently adds threads to a process that is already GVL-bound. - Production readiness. It is v0.1.0 and has never run in production. It serves Rails and Sinatra correctly in testing, which is a much weaker claim than being safe to put traffic on.
How it works
Wire protocol
8-byte header (u8 type | u24 reserved | u32 length) then payload. Full spec in
docs/PROTOCOL.md; the two implementations
(internal/wire/wire.go, gem/lib/puma_plus/wire.rb) are held byte-identical by
a golden-corpus round-trip test.
Go builds the complete Rack env and sends it as a flat key-value blob, so Ruby performs zero string transformation — it dups a frozen prototype hash and merges byteslices of one payload string. All the CGI-ification puma does across five layers happens once, in Go.
Where the queue clock starts
T1 first byte T2 headers parsed T2b body buffered (runnable)
T3 dispatched T4 app.call entered T5 app returns T6 flushed
Queue time is T3 − T2b, and nothing else — the interval where the request
was fully runnable and we had no capacity. That property is what makes it the
right control input; every other interval is polluted by things scaling cannot
fix. Bodies are buffered up to 1 MiB before the clock starts, so slow uploaders
land in body wait; above that they stream and the worker reports body_read_ns
for subtraction.
The controller
Tiered thresholds with hysteresis, cooldowns and gain probing — not PID. The
plant has discrete actuators whose costs differ by three orders of magnitude,
seconds of variable dead time, and an unknown gain sign. Decide() is a pure
function: no clocks, no IO. That makes the JSONL decision log replayable, so a
production incident becomes a reproducible test case:
tick 3 spawn_worker queue p95 503ms over target 50ms for 1 ticks (predicted 557ms, rho 0.04, 1 workers)
tick 4 none breach for 1 ticks but worker 1 is still booting
tick 6 none breach for 3 ticks but spawn cooldown has 0s left
tick 7 spawn_worker queue p95 302ms over target 50ms for 4 ticks (predicted 227ms, rho 0.25, 2 workers)
Declined decisions explain themselves too. That log is how the spawn cooldown got
retuned from 15s to 3s: it showed fifteen consecutive breaching ticks with the
controller sitting on its hands. The discrete-event simulator
(internal/control/sim) then quantified the trade — 15s→2s improved queue p95
from 1.597s to 405ms and cut shedding 6.5× — and showed why it was safe: at
realistic fork latencies the settled gate ("never fork while a worker is
still booting") is what actually prevents thrash, so the cooldown was redundant.
That gate originally approximated "still booting" as "younger than 2 seconds". It now reads the truth instead: a worker dials in only once its app is loadable, so the connection appearing in the registry is the readiness signal, and the shortfall between expected and observed capacity is exactly the capacity still on its way. The approximation was wrong in both directions — it blocked scaling for 2s after a worker was already serving, and declared ready a worker that might still have been booting a slow application.
Try it
gem install puma-plusThat installs two gems: puma-plus (this gem, plus a small C extension) and
puma-plus-core (the Go server, precompiled for your platform). No Go toolchain
is needed unless you are on a platform with no prebuilt binary, in which case the
source gem builds it at install time. See docs/PACKAGING.md.
In a Rails or Sinatra app, replace puma in the Gemfile:
gem "puma-plus"Then configure it the way you would configure puma, in config/puma-plus.rb:
port 9292
threads 5
workers 2
environment "production"
# puma-plus specific
autoscale min: 1, max: 8, target_queue_p95: "25ms"
activate_control_app "127.0.0.1:9293"
before_fork { ActiveRecord::Base.connection_handler.clear_all_connections! }
on_worker_boot { |i| ActiveRecord::Base.establish_connection }puma-plus # reads config/puma-plus.rb
puma-plus --port 3000 # flags beat the file
puma-plus --dry-run # show what it resolved, and exitDSL names match puma wherever the concept exists, so a puma config ports by
deleting lines rather than being rewritten — and a directive with no equivalent
is reported rather than silently ignored. Full reference in
docs/CONFIG.md.
From a checkout
go build -o puma-plus-server ./cmd/puma-plus
./puma-plus-server -app examples/hello/config.ru -workers 2 -threads 5
curl localhost:9292/
curl localhost:9293/stats?pretty=1 # puma-compatible JSON + queue metrics
curl localhost:9293/metrics # Prometheus
# Everything at once, with autoscaling
./puma-plus-server -app examples/hello/config.ru \
-listen 127.0.0.1:9292 -h2c \
-listen-tls 127.0.0.1:9443 -tls-cert cert.pem -tls-key key.pem \
-listen-h3 127.0.0.1:9443 \
-autoscale -min-workers 1 -max-workers 8 -target-queue-p95 25ms \
-decision-log /tmp/decisions.jsonl/stats is a strict superset of puma's shape, so existing dashboards and
pumactl-style tooling keep working, with everything new under a queue key.
Benchmarks
go build -o bench ./bench/cmd/bench
./bench smoke -server puma -path /hello -rate 300 -dur 10s
./bench run bench/scenarios/step-mix.yaml
./bench report -timeline pumaplus-auto1-6 bench/results/<run>Scenarios, fairness rules and the harness's known limits are documented in
bench/README.md.
Status
Experimental, v0.1.0, published to RubyGems. It has never run in production.
Works, and verified against real applications:
-
HTTP/1.1, h2c, h2 and HTTP/3, all reducing to the same request frame, so
the Ruby side never learns which one it was. Real TLS certificates
(
--tls-cert/--tls-key, orssl_bindin the config); a self-signed development certificate is generated when none is given. HTTP/3 is advertised withAlt-Svc, without which no browser would ever find it. -
Rack 3 conformance, validated with
Rack::Lintagainst rack 3.2.6 — zero violations across enumerable and streaming bodies, chunked upload, multi-value headers,rack.errorsand full hijack. Seedocs/RACK.md. - Rails 8.1 and Sinatra 4.2, booted from the published gems: preloading, forked workers, lifecycle hooks, ActiveRecord writes, HTML views.
-
Configuration by a Ruby file with puma's DSL names, plus
before_fork,on_worker_bootandon_worker_shutdown.docs/CONFIG.md. - Exact queue-time measurement and autoscaling driven by it.
- Go-managed WebSockets and WebTransport, where the app receives tagged messages rather than connections, with sticky or flexible routing.
-
Ractors as a unit of capacity (
--ractors), an experiment inside an experiment: same throughput as forked workers at 56% of the memory, and it is where the autoscaler's ramp behaviour was finally understood.docs/RACTORS.md.
Not implemented: early hints, partial hijack (use a streaming body), Rack 2
compatibility, phased restart, config reload, multi-app routing, adaptive shed
limits, state_path, stdout_redirect, and plugins. Lifecycle hooks do not run
inside Ractors — they are blocks, which cannot cross a Ractor boundary.
Known sharp edges: the app must be deep-freezable to use --ractors, which
excludes Rails; the config file is read twice, so top-level side effects in it
happen twice; and only aarch64-linux has been exercised end to end from the
published gem, the other platform binaries being cross-compiled and statically
verified.
Prior art
Both live in this repo's lineage and both were read closely while building it: puma for the request lifecycle, thread pool and cluster protocol, and puma-dev for the Go-in-front architecture. Where puma-plus diverges, the source says why and cites the file and line it is diverging from.