Skip to content

fix(web): scale web heap limit to container memory - #1569

Merged
brendan-kellam merged 4 commits into
mainfrom
brendan/fix-SOU-1933
Aug 11, 2026
Merged

fix(web): scale web heap limit to container memory#1569
brendan-kellam merged 4 commits into
mainfrom
brendan/fix-SOU-1933

Conversation

@brendan-kellam

@brendan-kellam brendan-kellam commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Fixes SOU-1933

Problem

Node's default old-space limit caps at roughly 4 GiB no matter how much memory the container has. app.sourcebot.dev runs with a 16 GiB limit, so the web process was capped at 4288 MiB — about a quarter of what it was allocated.

Once next-server reaches that ceiling, V8 stops doing cheap incremental sweeps and runs full stop-the-world mark-compact collections back to back. That blocks the single event loop, so every route slows simultaneously and the health endpoint stops answering.

Evidence from production:

  • A 6081 ms GET / (the slowest in 7 days; p50 is 58 ms) whose two dominant spans were prisma:client:operation at 1398 ms and 1248 ms — with prisma:engine:query children of 2.16 ms and 1.13 ms. The database did ~2 ms of work; the rest was the event loop being blocked.
  • In the same window every other route was slow too: auth 3.2 s, /api/source 2.3 s, changelog 2.0 s, commits 1.7 s, repos 1.7 s.
  • Postgres was idle throughout — 35 connections all idle, ~100% buffer cache hit rate, zero deadlocks.
  • CPU was not the constraint: 171 throttled periods out of 211,223 (0.08%).
  • next-server was at 4.79 GiB RssAnon against the 4.19 GiB V8 ceiling.
  • 392 Liveness probe failed events in 20 hours, and the pod has restarted 5 times. The container is killed roughly every 6 hours as the heap fills.

Change

Set --max-old-space-size-percentage=50 on the [program:web] block. Node resolves the percentage against the cgroup memory limit rather than host RAM, so it stays proportional at every deployment size.

Measured heap_size_limit, in MiB:

Container limit Node default pct=50 (this PR) pct=75
16 GiB (prod) 4288 8384 12480
2 GiB 1120 1120 (unchanged) 1632
512 MiB 259 259 (unchanged)
no limit (31.7 GiB host) 4288 8078

Those numbers imply Node's default is about 50% of the cgroup limit, capped at ~4 GiB. Two consequences:

  • The cap is the actual bug. It only binds above ~8 GiB, which is why this shows up on app.sourcebot.dev and not on typical self-hosted deployments.
  • Deployments at or below 8 GiB are unaffected, because 50% is exactly what Node already picks below the cap. This change only alters behaviour where the cap was binding, which keeps the blast radius on the one deployment that has the problem.

Scoping it to [program:web] via supervisord's environment= keeps the backend worker and zoekt on their existing defaults.

Tradeoffs worth reviewing

Why 50 and not more. At a 16 GiB limit, 50% leaves roughly 7.7 GiB for page cache, comfortably above the ~5.8 GiB of file cache zoekt currently uses for its EFS-backed index. 75% would cut that to ~3.6 GiB, and EFS major faults are network round-trips, so search latency would likely suffer. 75% also raises the ceiling for small self-hosted installs (a 2 GiB deployment would go from 1120 to 1632 MiB) for no benefit to them.

This is a mitigation, not a cure. The heap grows at roughly 700–800 MiB/hour with no observed plateau, which is what produces the ~6 hour restart cadence. A higher ceiling extends that to ~11 hours; it does not stop it. If the growth is reclaimable garbage, this change genuinely fixes the problem. If it is a leak, the ceiling only delays the wall, and each full GC gets longer because GC cost scales with live heap size. Determining which requires post-GC heap floor data over time — see the follow-ups.

NODE_OPTIONS set at the container level no longer reaches the web process (the backend and zoekt still see it), and because this flag outranks --max-old-space-size regardless of order, an operator's own heap sizing will not take effect for next-server. Worth calling out for self-hosters.

Follow-ups

  • The liveness probe uses timeoutSeconds: 1 with failureThreshold: 5. A 1 second timeout on a Next.js health endpoint will trip on any GC pause, which is what converts "briefly slow" into "dead container". Raising it is independent of this change and arguably higher-impact.
  • Find what holds the memory. feat(web): expose Prometheus metrics for the web process #1570 adds heap, GC, and event-loop-lag metrics for the web process, which makes the post-GC floor observable and answers the garbage-vs-leak question without attaching a debugger to production.

Test plan

  • sh -n and busybox sh -n (the container's actual shell) both clean
  • supervisord.conf parses; supervisord's own parser yields {'NODE_OPTIONS': '--max-old-space-size-percentage=50'}
  • Heap ceiling confirmed in the production container: 4288 MiB → 8384 MiB
  • Percentage confirmed to resolve against the cgroup limit, not host RAM (16 GiB container on a 31.7 GiB host yields 8192 + 192, not 15852 + 192)
  • Confirmed a 2 GiB container is unchanged at 1120 MiB, so smaller deployments are unaffected
  • Verified graceful behaviour with no cgroup limit (falls back to a percentage of host RAM, no crash)
  • backend and zoekt confirmed to have no environment line
  • Post-deploy: confirm heap_size_limit in the running web process and watch restart cadence

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Increased the web process’s available memory capacity based on container resources.
    • Reduced extended garbage-collection pauses that could affect larger deployments.
    • Improved stability and responsiveness under higher workloads.
  • Documentation
    • Added an Unreleased changelog entry describing the memory-handling improvements and performance impact.

Note

Medium Risk
Changes production memory allocation for the web process and overrides container-level NODE_OPTIONS for that process only. Incorrect sizing could pressure zoekt page cache or delay OOM/restart behavior if heap growth is a leak.

Overview
Raises the Next.js web process heap ceiling on large containers by setting NODE_OPTIONS=--max-old-space-size-percentage=50 in the [program:web] supervisord block.

This removes Node's ~4 GiB default old-space cap so the heap scales with the cgroup memory limit (e.g. ~8 GiB on a 16 GiB deployment), reducing stop-the-world GC pauses. Backend and zoekt keep their existing defaults; smaller deployments at or below ~8 GiB are unchanged.

Reviewed by Cursor Bugbot for commit b27910b. Bugbot is set up for automated code reviews on this repo. Configure here.

Node's default old-space limit caps at ~4GiB regardless of how much memory
the container actually has. On larger deployments the web process fills that
ceiling and V8 falls into back-to-back full mark-compact collections, which
block the event loop for seconds at a time and slow every route at once.

Set --max-old-space-size-percentage on the web program so the heap scales
with the container's memory limit. Node reads the cgroup limit, so this is
proportional on every deployment size. Scoped to [program:web] via
supervisord so the backend and zoekt are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

This comment has been minimized.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b3173842-5bba-44ca-b5a1-fd8bc951953e

📥 Commits

Reviewing files that changed from the base of the PR and between 2e2e2ee and b27910b.

📒 Files selected for processing (1)
  • supervisord.conf

Walkthrough

The web process now sets Node.js’s maximum heap size to 50% of container memory. The changelog documents removal of the approximately 4 GiB heap cap.

Changes

Web runtime configuration

Layer / File(s) Summary
Node.js heap-size setting
supervisord.conf, CHANGELOG.md
The web process sets NODE_OPTIONS to use 50% of container memory for the Node.js heap. Backend stderr redirection remains enabled. The changelog records the heap-cap correction.

Estimated code review effort: 1 (Trivial) | ~5 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: scaling the web process heap limit with container memory.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch brendan/fix-SOU-1933

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
supervisord.conf (2)

19-19: 🩺 Stability & Availability | 🔵 Trivial

Measure aggregate cgroup memory before relying on 75%.

--max-old-space-size-percentage=75 limits V8 old-space, not total web RSS. Because backend and zoekt share the container, measure aggregate memory during representative load. Lower the percentage if usage approaches the cgroup limit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supervisord.conf` at line 19, Measure aggregate cgroup memory usage for both
backend and zoekt under representative load before finalizing the NODE_OPTIONS
max-old-space-size-percentage setting. If combined RSS approaches the container
limit, lower the 75% value in the supervisord environment configuration.

19-19: 🩺 Stability & Availability | 🔵 Trivial

Size the container for all supervised processes

--max-old-space-size-percentage=75 limits V8 old space for the web process. It does not limit total process memory. Account for backend, Zoekт, and native allocations when setting the container memory limit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supervisord.conf` at line 19, Update the supervisord environment
configuration to size the container memory limit for all supervised processes,
including the web process, backend, Zoekт, and native allocations, rather than
relying only on NODE_OPTIONS V8 old-space limits. Set the container-level limit
using the deployment’s established memory configuration mechanism.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@supervisord.conf`:
- Line 19: Measure aggregate cgroup memory usage for both backend and zoekt
under representative load before finalizing the NODE_OPTIONS
max-old-space-size-percentage setting. If combined RSS approaches the container
limit, lower the 75% value in the supervisord environment configuration.
- Line 19: Update the supervisord environment configuration to size the
container memory limit for all supervised processes, including the web process,
backend, Zoekт, and native allocations, rather than relying only on NODE_OPTIONS
V8 old-space limits. Set the container-level limit using the deployment’s
established memory configuration mechanism.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e35bc427-d40a-4dde-9331-c2d1584c00c1

📥 Commits

Reviewing files that changed from the base of the PR and between eb46976 and 3a76dcd.

📒 Files selected for processing (2)
  • CHANGELOG.md
  • supervisord.conf

brendan-kellam and others added 2 commits August 11, 2026 15:02
At a 16GiB limit this gives the web process an 8384MiB ceiling (up from
4288MiB), which still resolves the GC thrash while leaving roughly 7.7GiB for
page cache. zoekt currently uses ~5.8GiB of file cache for its EFS-backed
index, and 75% would have squeezed that to ~3.6GiB.

50% also matches Node's own default curve below the ~4GiB cap, so deployments
at or under 8GiB are unaffected by this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brendan-kellam
brendan-kellam merged commit 4fbf359 into main Aug 11, 2026
11 of 12 checks passed
@brendan-kellam
brendan-kellam deleted the brendan/fix-SOU-1933 branch August 11, 2026 22:04

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
supervisord.conf (1)

19-19: 🩺 Stability & Availability | 🔵 Trivial

Verify that Node.js uses the container cgroup limit.

Node.js defines this percentage using “available system memory.” The existing cgroup reader in packages/web/src/features/billing/systemInfo.ts does not prove that the Node.js process uses the same limit. Measure v8.getHeapStatistics().heap_size_limit inside the 16 GiB production container and compare it with the cgroup limit before relying on the stated 8,384 MiB ceiling. (nodejs.org)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supervisord.conf` at line 19, Verify the effective Node.js heap limit in the
16 GiB production container by measuring v8.getHeapStatistics().heap_size_limit
and comparing it with the cgroup limit reported by the existing systemInfo
logic. Update the supervisord NODE_OPTIONS percentage or document the validated
ceiling only after confirming Node.js uses the container cgroup limit; do not
rely on the stated 8,384 MiB value without this measurement.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@supervisord.conf`:
- Line 19: Verify the effective Node.js heap limit in the 16 GiB production
container by measuring v8.getHeapStatistics().heap_size_limit and comparing it
with the cgroup limit reported by the existing systemInfo logic. Update the
supervisord NODE_OPTIONS percentage or document the validated ceiling only after
confirming Node.js uses the container cgroup limit; do not rely on the stated
8,384 MiB value without this measurement.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 00bbde17-f52d-422a-955f-7e1ccb98cea9

📥 Commits

Reviewing files that changed from the base of the PR and between 3a76dcd and 2e2e2ee.

📒 Files selected for processing (1)
  • supervisord.conf

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