fix(server): default to 4 API workers and fix the WEB_SERVER_CONCURRENCY guidance - #4385
Merged
gantoine merged 3 commits intoSep 7, 2026
Conversation
"2 x CPU cores + 1" is gunicorn's guidance for sync workers. RomM runs UvicornWorker with sync endpoints served from Starlette's threadpool, so the rule does not transfer: on a 16-core host it measured 3x slower than 4 workers and cost 5.31 GB PSS. The variable also only reaches the gunicorn API server; background tasks run in the separate RQ worker processes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Greptile SummaryUpdates the example environment configurations to replace the CPU-based web-worker heuristic with a four-worker recommendation.
Confidence Score: 5/5The documentation and example configuration changes appear safe to merge. The revised values are consistent across both files, the variable’s documented scope matches its gunicorn usage, and no concrete behavioral or repository-rule violation remains. Important Files Changed
Reviews (1): Last reviewed commit: "docs(env): replace the WEB_SERVER_CONCUR..." | Re-trigger Greptile |
Spinnich
added a commit
to Spinnich/docs
that referenced
this pull request
Sep 7, 2026
"2 x CPU cores + 1" is gunicorn's guidance for sync workers and does not apply to RomM's UvicornWorker setup. Mirrors rommapp/romm#4385 so the published table is correct before the next pinned-ref bump regenerates it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A single worker was the reason a lone user loading a gallery measured 6.474 s worst-case TTFB against 1.939 s at 4 workers. The multi-worker path is already supported: the log forwarder takes a Redis lock so exactly one worker relays. Only installs that never set WEB_SERVER_CONCURRENCY move; any explicit value is preserved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gantoine
approved these changes
Sep 7, 2026
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.
Description
RomM ships one API worker by default, and documents raising it with a heuristic that makes things worse. This PR fixes both: it moves the default to 4 and replaces the recommendation with one that matches how RomM actually serves requests.
Why
2 x CPU cores + 1is the wrong ruleIt is gunicorn's guidance for sync workers, where each worker serves exactly one request at a time and you over-provision to hide blocking I/O. RomM runs
--worker-class uvicorn_worker.UvicornWorkerwith--worker-connections 1000(docker/init_scripts/init:140-146), and its endpoints are plaindef(e.g.get_romsinbackend/endpoints/roms/__init__.py), so Starlette already serves them concurrently in its threadpool inside a single worker. The rule does not transfer, and following it costs both latency and memory.The old note also said the setting covers "API calls and tasks". It does not.
WEB_SERVER_CONCURRENCYappears exactly once in the entrypoint, on the gunicorn API server (init:141). Background tasks run in the separate RQ worker processes started bystart_bin_rq_worker/start_bin_rq_scan_worker.Measurements
Real gunicorn, 14 concurrent gallery requests, RomM 5.2.0, 4,644 ROMs over 4 platforms, 16-core / 7.4 GB host (so the documented value works out to 33). Slowest TTFB in the burst:
2 x cores + 1)Returns flatten after about 4 workers. At 33, processes contend for 16 cores on CPU-bound work and lose more than they gain: 3x slower than 4 workers, and barely better than a single worker.
Memory is the harder limit at the top end. 33 workers measured 5.31 GB PSS (~160 MB per worker, shared pages counted once), leaving 587 MB free on a 7.4 GB host. On an 8-core NAS the old recommendation gives 17 workers, roughly 2.7 GB, competing with every other container. Four workers costs about 640 MB.
Changes
docker/init_scripts/init-${WEB_SERVER_CONCURRENCY:-1}becomes${WEB_SERVER_CONCURRENCY:-4}env.template-WEB_SERVER_CONCURRENCY=4 # Number of API worker processes, not background tasks; ~160MB RAM each, raise only for several concurrent usersexamples/docker-compose.example.yml-WEB_SERVER_CONCURRENCY=4 # Workers for API calls, not background tasks. ~160MB RAM each, raise only for several concurrent usersAll three now agree. Before this PR they disagreed three ways (
1,1,3).Who this actually changes
Only installs that never set the variable.
${VAR:-4}substitutes the default whenWEB_SERVER_CONCURRENCYis unset or empty, so any explicit value is preserved:WEB_SERVER_CONCURRENCY18So someone who deliberately pinned 1 on a small box keeps 1. The cost for everyone else is roughly 480 MB more RSS in exchange for the numbers above.
Running more than one worker is already a supported configuration rather than a new code path:
start_log_forwarderinbackend/endpoints/sockets/logs.pytakes a Redis lock precisely so that "when more than one web worker is running (WEB_SERVER_CONCURRENCY > 1), exactly one forwards and clients don't receive duplicate lines".Docs
docs/resources/snippets/env-vars.mdinrommapp/docsis generated from thisenv.templatebyscripts/gen_env_vars.py, so it picks the new wording and default up on the next release bump. I confirmed the new line still parses under that generator'sKEY=VALUE # COMMENTrules (two spaces before the#preserved, correct section, not flagged required).Companion PR: rommapp/docs#130 hand-syncs the same row into the published table so the docs site is correct before that bump. It is optional; this PR is the actual fix.
Not changed
Each worker process gets its own SQLAlchemy
QueuePoolwith the library defaultspool_size=5, max_overflow=10, never overridden inbackend/handler/database/base_handler.py, so 15 connections per process. Four workers ceiling at 60 connections, plus 15 each for the RQ worker and RQ scan worker, which sits comfortably under MariaDB's defaultmax_connectionsof 151. (At the old recommended 33 workers it would have ceilinged at 495, over-subscribed on paper, though pools only grow on demand and realistic bursts peaked at 14-17 connections even with 16 workers.) The new default has plenty of headroom, so I left the pool settings alone.Fixes rommapp/docs#129
Checklist
AI disclosure
The benchmarking, analysis and wording in this PR were produced with Claude Code. I reviewed and verified every claim against the source before submitting.
🤖 Generated with Claude Code