Run Puma in single mode on Render free tier#22
Merged
Conversation
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.
2 tasks
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
`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