Skip to content

Run Puma in single mode on Render free tier#22

Merged
raghubetina merged 1 commit into
mainfrom
fix/puma-single-mode-on-free-tier
May 18, 2026
Merged

Run Puma in single mode on Render free tier#22
raghubetina merged 1 commit into
mainfrom
fix/puma-single-mode-on-free-tier

Conversation

@raghubetina
Copy link
Copy Markdown
Contributor

Summary

`WEB_CONCURRENCY=1` was set in `render.yaml` with the comment "Single worker to fit in 512MB", but that's the opposite of what it does. `WEB_CONCURRENCY` is the number of workers Puma forks in addition to the master, so `1` means master + 1 worker = 2 Puma processes in cluster mode.

Combined with `SOLID_QUEUE_IN_PUMA=true` (which spawns 4 more subprocesses), that's 6 Ruby processes inside a 512 MB container:

process RSS
Puma master ~150MB
Puma worker (forked) ~150MB
SQ supervisor ~80MB
SQ dispatcher ~80MB
SQ worker ~80MB
SQ scheduler ~80MB
total ~620MB → exceeds 512MB → OOM-loop

`WEB_CONCURRENCY=0` puts Puma in single mode (master serves HTTP directly, no fork). That removes the ~150 MB worker process and brings steady-state RSS under the cap. There's no throughput cost — cluster mode with a single worker is pure overhead anyway, since one Puma process is doing the serving either way.

How this surfaced

raghubetina/aplace is running the same config (Puma cluster + `SOLID_QUEUE_IN_PUMA` on Render Starter, also 512 MB). After a routine deploy the container started OOM-restarting every ~90 seconds while still reporting `live` status — HTTP returned 502 most of the time. Render's memory metric showed the sawtooth pattern (~230 MB at boot → ~530 MB at OOM-kill → restart). Fixed by the same one-line change. See raghubetina/aplace#54.

Any student project that forks this template and adds nontrivial gems (image_processing, ML/embedding clients, etc.) will hit the same wall — worth fixing in the template before more forks inherit it.

Test plan

  • Deploy a fresh project from this template after the change — `render.yaml` should provision a service that boots and stays alive.
  • Memory should hover well under 400 MB instead of bouncing off 512 MB.

WEB_CONCURRENCY=1 was set with the comment "Single worker to fit
in 512MB", but that's the opposite of what it does: WEB_CONCURRENCY
is the *worker* count Puma forks *in addition to* the master, so
=1 actually means master + 1 worker = 2 Puma processes in cluster
mode.

Adding SOLID_QUEUE_IN_PUMA on top spawns four more subprocesses
(supervisor, dispatcher, worker, scheduler). That's six Ruby
processes in one 512MB container:

  master (~150MB) + worker (~150MB) + 4 SQ procs (~80MB each)
  ≈ 620MB → exceeds 512MB → OOM-loop

Setting WEB_CONCURRENCY=0 drops Puma into single mode (master
serves HTTP directly, no fork). That saves the ~150MB worker
process and brings steady-state RSS under the cap. With only one
Puma process there's no throughput benefit from cluster mode
anyway — the worker is pure overhead.

Hit by:
- raghubetina/aplace running an identical config (Puma cluster +
  SOLID_QUEUE_IN_PUMA on Render Starter, also 512MB). The deploy
  appeared green but the container OOM-restarted every ~90s,
  serving HTTP 502 most of the time. Fixed by the same change.

Any student project on this template that adds memory-hungry
gems (image_processing, ML/embedding clients, etc.) will hit
the same wall — best to fix it in the template before more
forks inherit the bug.
raghubetina added a commit that referenced this pull request May 18, 2026
This branch's puma.rb adds `solid_queue_mode :async if ENV["SOLID_QUEUE_IN_PUMA"]`,
but the lockfile resolved to solid_queue 1.1.4, which has no such DSL method.
Booting in production crashes immediately with:

  config/puma.rb:47:in 'Puma::DSL#_load_from': undefined method
  'solid_queue_mode' for an instance of Puma::DSL (NoMethodError)

Async supervisor mode was re-introduced in solid_queue 1.3.0
(rails/solid_queue#644); the earlier 0.4 implementation was
removed in 0.7. Pinning to ~> 1.3 documents the minimum that
matches this branch's puma.rb and updates the lockfile to 1.4.0.

Caught by deploying a marketplace smoke-test app built from this
branch + #22 + #24 to Render free tier.
@raghubetina raghubetina merged commit 8899422 into main May 18, 2026
1 check passed
@raghubetina raghubetina deleted the fix/puma-single-mode-on-free-tier branch May 18, 2026 16:51
raghubetina added a commit that referenced this pull request May 18, 2026
* Run Solid Queue in async mode on Render free tier

Companion to #22 (WEB_CONCURRENCY=0). Free tier (512MB) can't fit
SQ's default fork mode, which spawns supervisor + worker +
dispatcher + scheduler subprocesses for ~460MB of RSS overhead on
top of Puma. The container OOM-cycles every ~7-15 min — the deploy
still looks "live" but HTTP returns 502s during each restart
window.

`solid_queue_mode :async` (documented switch in
lib/puma/plugin/solid_queue.rb of the gem) tells the plugin to
run worker/dispatcher/scheduler as threads inside the Puma master
process. The 4 SQ subprocesses collapse into ~50MB of thread
overhead in Puma. Container RSS drops from ~530MB (cycling into
OOM) to a flat ~300MB.

Trade-off per SQ's own docs ("Only use async if you know what
you're doing and have strong reasons to"):
- Less isolation between SQ and Puma. A leaky/hung SQ thread
  affects request serving. For low-traffic student projects this
  is the right call; revisit if upgrading to a paid plan.
- No inter-process parallelism for jobs. Free tier is already
  0.1 vCPU, so this isn't real lost throughput.
- Recurring tasks and concurrency controls still work unchanged.

The DB pool bump (5 → 8) is non-optional: previously the pool was
sized for Puma's 3 request threads only; now the same process
also runs 3 SQ worker threads + dispatcher + scheduler. Under any
load the old pool=5 would throw ConnectionTimeoutError. The 5 ->
8 default still respects DB_POOL env override for projects that
need finer control.

Diagnosed and validated on raghubetina/aplace:
raghubetina/aplace#58 — RSS metric showed
the predicted plateau at ~300MB with zero growth after deploy.

* Pin solid_queue ~> 1.3 for solid_queue_mode :async support

This branch's puma.rb adds `solid_queue_mode :async if ENV["SOLID_QUEUE_IN_PUMA"]`,
but the lockfile resolved to solid_queue 1.1.4, which has no such DSL method.
Booting in production crashes immediately with:

  config/puma.rb:47:in 'Puma::DSL#_load_from': undefined method
  'solid_queue_mode' for an instance of Puma::DSL (NoMethodError)

Async supervisor mode was re-introduced in solid_queue 1.3.0
(rails/solid_queue#644); the earlier 0.4 implementation was
removed in 0.7. Pinning to ~> 1.3 documents the minimum that
matches this branch's puma.rb and updates the lockfile to 1.4.0.

Caught by deploying a marketplace smoke-test app built from this
branch + #22 + #24 to Render free tier.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant