Serve the site on every core, not on one of twenty-four - #186
Merged
Conversation
The site was dark on 2026-09-07 while twenty-two of its twenty-four CPUs sat idle. A residential-proxy fleet walked /topics/*, /api/topics/* and the reader at about 140 requests a second, five hundred requests from five hundred distinct addresses with no path asked for twice, so neither the throttle nor any cache touched one of them. The container held a single `node server.mjs`. A topic page costs roughly 150ms of JavaScript and JavaScript renders on one thread, so the whole site could serve about seven requests a second whatever hardware it stood on. The rest queued behind the in-flight ceiling until the event loop stopped getting back to accept() and the edge proxy began reporting connection dial timeouts, at which point readers were as locked out as the fleet. The ceiling from 2026-09-03 was the right answer to a memory problem and is untouched here. This is a different failure wearing the same symptom: a process cannot work on more than one thing at a time, and no ceiling fixes that. So the primary now forks one server per CPU the container is allowed and the workers share the port. Three things that had to be got right: - The budgets come from the cgroup, not from `os`. This container reports 48 host CPUs and 393 GB while being allowed 24 CPUs and 24 GB; forking per host CPU would put twice the runnable threads on the quota, and sizing heap from host memory would promise each worker forty times what the container has. - The in-flight ceiling stays a container-wide number and is divided between the workers. Leaving 128 on each of sixteen would raise the real ceiling to 2,048 and hand back the outage it was written for. - Each worker's heap ceiling is computed by the primary and passed on the worker's command line. The Dockerfile's NODE_OPTIONS is sized for one process holding the whole container, and inheriting it would tell every worker it may take half of one. WEB_WORKERS overrides the count and WEB_WORKERS=1 is the way back to the single process, because a bug that only shows up with more than one of something is diagnosed by turning the something off. Measured locally on the same build and the same four topic pages: one worker 28.3 req/s at p50 475ms, four workers 50.3 req/s at p50 234ms. Capacity is not a defence, and workers.js says so where someone will read it. A fleet twice this size puts the site back where it started; what actually stops a distributed scrape is refusing it before it costs a render. This buys the room to go build that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPvk8mVEpxWwTRFby9m8VT
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.
What is wrong
rssamplifier.com is down as I write this, and the container is 92% idle.
A residential-proxy fleet is walking
/topics/*,/api/topics/*and the reader at ~140 req/s. In a 3.5-second sample of Railway's HTTP log: 500 requests, 500 distinct source IPs, no path requested twice (329 distinct topic slugs). The throttle meters a caller and this caller carries nothing twice; a cache needs a repeat and there are none. Neither refuses a single request.The container holds one
node server.mjs. Measured on the live container:cpu.max2400000 100000— 24 CPUs/proc/1)memory.current/memory.maxconnection dial timeoutA topic page costs ~150 ms of JavaScript, and JavaScript renders on one thread. That caps the site near 7 req/s no matter what it is standing on. Everything above that queued behind the in-flight ceiling until the event loop stopped getting back to
accept(), and then readers were locked out exactly as hard as the fleet was.This is not the 2026-09-03 OOM — memory is at 29% of the limit. Same symptom, different cause.
What this does
The primary forks one server per CPU the container is allowed; the workers share the port via
node:cluster.Three things that had to be right:
os. This container reports 48 host CPUs and 393 GB of host RAM while being allowed 24 CPUs and 24 GB. Forking per host CPU would put twice the runnable threads on the quota; sizing heap from host memory would promise each worker forty times what the container has.loadShed.jswas written to prevent.WEB_MAX_INFLIGHTkeeps meaning what it meant before there were workers.NODE_OPTIONS=--max-old-space-size=12288is sized for one process holding the whole container; inheriting it would tell every worker it may take half of one.WEB_WORKERSoverrides the count.WEB_WORKERS=1is the way back to the single process — worth having, because a bug that only appears with more than one of something is diagnosed by turning the something off.What it does not do
Capacity is not a defence, and
workers.jssays so where someone will read it. This absorbs this fleet; one twice the size puts the site back where it started. What actually stops a distributed scrape is refusing it before it costs a render. The header checks in the x402 gateway do not catch this fleet — it already sendsSec-Fetch-Mode. This buys the room to go build the next rung.One deliberate loosening, documented in
server.mjs: module state (the throttle's counters, the traffic tally, the verified-key cache) is now per worker. For the counters that bound memory that is the correct place for them. For the counters that meter a caller it is a loosening — a client on a keep-alive connection stays on one worker so its own limit is intact, but a caller opening fresh connections is metered by each worker separately. Left as-is on purpose: the traffic this was written for arrives one request per address and defeats a per-caller limit outright, and tightening those limits sixteenfold during an outage would refuse readers to no purpose.Verification
node --test test/*.test.js— 403 pass, 0 fail (15 of them new/changed).pnpm build— green.WEB_WORKERS=4: primary forked 4, all bound the port, each got--max-old-space-size=2398and an in-flight cap of 32 (32×4 = 128, unchanged).The local box is not the container — its ratio is held down by fewer cores and a shared remote database. On a container whose only constraint is single-threaded SSR the gain should be considerably larger.
Deploying
Merging redeploys the web service. If it misbehaves, set
WEB_WORKERS=1on therssamplifier.comservice and redeploy — that is the pre-existing arrangement exactly.🤖 Generated with Claude Code
https://claude.ai/code/session_01KPvk8mVEpxWwTRFby9m8VT