13 min read
If you run a Go service on a 512 MB or 1 GB VPS (or a tight container), you have probably seen this movie: CPU looks busy, RSS creeps toward the limit, the process never quite dies — and every request suddenly takes hundreds of milliseconds. Operators blame “GC pauses.” Often the real villain is swap thrashing feeding a GC that keeps trying to reclaim a heap that no longer fits in RAM.
This post is a practitioner playbook for that failure mode. You will get:
GOGC and soft GOMEMLIMIT interact with Linux memory and swap.Lab honesty: Critical RSS / cgroup-swap / p99 / GC-cycle tables below are from a ShopperCove lab API (tiny retain/churn HTTP service) under cgroup v2 limits — not a production SaaS — run 29 Sep 2026 (IST) on this Linux box: Go 1.24.4, cgroup 512 MiB (and a 384 MiB thrash pair), hey load, GODEBUG=gctrace=1. Host-level vmstat si/so and Grafana screenshots were not captured; cgroup swap counters stand in for swap pressure. This was not a dedicated 512 MB VPS — same failure mode, different neighbors/disk. Optional follow-ups: Grafana panels, host vmstat, automemlimit compare, and a real-service alloc hotspot.
Related ShopperCove reading (memory theme, different stack): Spring Boot vs Quarkus on a 512 MB VPS. That post is about JVM framework footprint; this one is about Go GC + swap behavior. They complement each other — do not merge them into one “everything memory” mega-post.
Since Go 1.19, the runtime accepts a soft memory limit via the GOMEMLIMIT environment variable or runtime/debug.SetMemoryLimit. Official docs define the limit over Go-managed memory roughly as Sys − HeapReleased (or the equivalent runtime/metrics classes), and explicitly exclude things like the mapped binary, memory managed in other languages, and some OS-held memory. See:
Important consequences on a tiny VPS:
GOGC alone does not know your box sizeGOGC sets a ratio of new heap to live heap. Double GOGC ≈ double heap overhead and roughly half GC CPU in steady state (see the GC guide’s math). That is great when RAM is plentiful. On a 512 MB box, a transient live-heap spike + default GOGC=100 can push total footprint into swap — or into the OOM killer — even though “GOGC is fine for throughput.”
GOMEMLIMIT exists precisely because GOGC alone cannot encode “this container only has 512 MiB.”
Without swap, the kernel’s OOM killer is brutal but obvious: process gone, restart, page the on-call.
With swap enabled (common on cheap VPS images), the kernel pages cold anonymous memory to disk. Your Go process stays in ps. Health checks may still pass. Meanwhile:
That is thrashing: the system is busy moving pages, not serving users.
Use this as a triage card. Lab captured cgroup memory.events / swap counters and hey p99. Optional next lab: Grafana screenshots of each host signal (enrichment, not a blocker).
| Signal | Swap thrashing (bad “alive”) | Clean OOM / cgroup kill | Healthy under load |
|---|---|---|---|
| Process present? | Yes | No (restart loop) | Yes |
si / so in vmstat 1 | Sustained non-zero | Spike then death | ~0 |
Disk util (iostat) | High on swap device | Brief | Matches app I/O only |
GC CPU (/gc/cycles or gctrace) | Elevated, frequent cycles | N/A after kill | Moderate, stable |
| Latency p99 | Climbs orders of magnitude | Timeouts / connection resets | Moves with load |
| dmesg / journal | Rarely “Killed process” | Out of memory: Killed process or cgroup OOM | Quiet |
cgroup memory.events (v2) | high / pressure rising | oom / oom_kill increments | Stable |
Prefer a clean OOM over silent thrashing when the alternative is serving garbage latency for hours. Many production setups intentionally set vm.swappiness=1 or disable swap on app nodes for exactly this reason — with the trade-off that you must size RAM and GOMEMLIMIT correctly.
Also record whether swap is enabled (swapon --show) and swappiness (sysctl vm.swappiness).
Lab box (measured 29 Sep 2026 IST):
| Field | Value |
|---|---|
| Provider / shape | Shared Linux lab box (cgroup slice; not a rented 512 MB VPS) |
| Host RAM | 16 GiB |
| Host swap | 16 GiB file (/var/swapfile); vm.swappiness=10 |
App cgroup memory.max | 512 MiB (phases A–D); 384 MiB (thrash phases E–F) |
App cgroup memory.swap.max | 256 MiB / 512 MiB / 0 (varied by phase) |
Go / GOMAXPROCS | go1.24.4 / 2 |
| Load | hey — /json 2000×20 (A–D) or 1000×20 (E–F); /churn 500×20 |
Limitation (honest): host vmstat si/so was not the primary signal on this shared box — used cgroup memory.swap.current / .peak instead. Capturing a clean host vmstat strip is an optional next lab.
Useful starting points from the GC guide diagnostics section:
runtime.gcBgMarkWorker, runtime.mallocgc, runtime.gcAssistAlloc.GODEBUG=gctrace=1 for per-cycle lines (stderr).runtime/metrics or expvar//debug/vars if you expose them — especially total memory classes vs heap released.Measured headroom sketch for a 512 MiB cgroup (this lab):
| Knob | Lab value | Why / observed |
|---|---|---|
cgroup memory.max | 512 MiB | Hard ceiling — phase A peak hit ~512 MiB then OOM |
GOMEMLIMIT | 400 MiB | Soft Go-managed cap; leave ~10%+ for non-Go RSS |
| Working live/retain set | ≤ ~280–350 MiB | Phase D: 280 MiB retained → /json p99 1.3 ms, 20 GC cycles |
GOGC | 100 | With limit as backstop |
cgroup memory.swap.max | 0 preferred for app | Phase E with swap on → alive + 277 MiB cgroup swap + /churn p99 398 ms |
Do not set GOMEMLIMIT below a live heap you cannot shrink — phases B/C retained 420 MiB with limit 400 MiB and burned ~1000 GC cycles (~6% GC CPU) with /json p99 11–14 ms.
Pair memory graphs with percentiles, not only averages. (See the companion draft: Why Your Average Latency Graph Is Lying.) If p50 is fine and p99 is catastrophic while si/so are hot, you are looking at thrash, not “the GC is slow in general.”
Internal links:
| Choice | When it makes sense | Cost |
|---|---|---|
| Disable / minimize swap | Single-tenant app VPS; you can page humans on crash | Harder failure mode; need correct sizing |
| Keep small swap | Emergency cushion for rare spikes; accept risk | Must alert on swap-in, not only RSS |
| Raise RAM | Cheapest operational win when traffic is real | Money |
Lab policy (chosen): Prefer fail-fast for the Go app cgroup — set memory.swap.max=0 once GOMEMLIMIT and live set fit. Host may keep swap for the machine; the service slice should not thrash. Evidence: phase E (swap allowed) stayed alive with ~277 MiB cgroup swap and 398 ms churn p99; phase A filled swap then OOM'd — both worse UX than a clean restart after correct sizing (phase D).
GOMEMLIMIT with headroomFrom the official GC guide suggested uses:
Libraries such as KimMachineGun/automemlimit can derive a limit from cgroup; this lab used a manual GOMEMLIMIT=400MiB / 300MiB env. Optional next lab: compare automemlimit vs manual env (enrichment, not a blocker).
GOGC only after the limit is sanePattern that often works for services:
GOMEMLIMIT to a soft cap under the cgroup.GOGC so the average case is economical; let the limit clip spikes.GOGC if you are OOMing without thrashing and need a smaller peak heap sooner.Setting GOGC=off with a memory limit maximizes economy if live heap reliably fits. If live heap routinely exceeds the limit, you get constant GC and a stalled service — worse than OOM. The GC guide calls this out explicitly.
Knobs buy time. Allocation rate still drives GC frequency. Use heap profiles (inuse_space / alloc_space) and escape analysis (go build -gcflags=-m=3) as described in the GC guide's optimization sections. This lab’s API is a retain/churn toy under cgroup limits — not a production SaaS. Optional next lab: before/after alloc hotspot on a real service (not claimed here; optional).
Minimum alert set for tiny VPS Go services:
memory.events oom / oom_kill| Approach | Advantages | Disadvantages |
|---|---|---|
Default GOGC, no GOMEMLIMIT, swap on | Zero config; survives small spikes | Classic thrash + mysterious p99 |
GOMEMLIMIT + headroom, swap off | Predictable; fail fast; docs-aligned | Needs correct sizing; spikes become restarts |
Raise GOGC only | Less GC CPU when RAM exists | Ignores hard RAM ceiling |
| More RAM | Fixes class of problems | Cost; can hide alloc bugs |
Disable GC (GOGC=off) without limit | — | Dangerous on tiny VPS; avoid |
No testimonials. No “we cut latency 70% overnight” claims without a lab.
Scenario: cgroup v2 slice on lab box; ShopperCove lab API (tiny HTTP JSON retain/churn service — not a production SaaS); Go 1.24.4; GOMAXPROCS=2; GOGC=100; load with hey.
| Phase | RSS | Cgroup swap | p99 | Notes |
|---|---|---|---|---|
| Idle | ~7.3 MiB | 0 | — | Fresh process |
A — 512 MiB cgroup, no GOMEMLIMIT, retain 420 MiB, then /churn | 429 MiB → OOM kill | peak 256 MiB (cap) then death | /json 2.2 ms before death; churn refused | oom_kill=1; swap.events fail=6 |
| E — 384 MiB RAM + 512 MiB swap, no limit, retain 320 MiB + churn | ~386 MiB RSS; mem pinned at max | ~277 MiB swap; oom=0 | /churn 398 ms | Alive + thrash (preferred anti-pattern demo) |
B — 512 MiB, GOMEMLIMIT=400MiB, retain 420 (live > limit) | 429 MiB alive | no new swap growth this run | /json 13.5 ms; /churn 94.8 ms | 994 GC cycles; GC CPU ~6.4% — soft limit cannot free live set |
D — 512 MiB, GOMEMLIMIT=400MiB, retain 280, swap.max=0 | 289 MiB after retain; 386 MiB peak under churn | 0 | /json 1.3 ms; /churn 29.9 ms | Goal-ish: 20 GC cycles, GC CPU ~0.24% |
Also measured: Phase F (GOMEMLIMIT=300MiB on 384 MiB cgroup, live 320 MiB) avoided swap balloon but burned 1436 GC cycles (~11% GC CPU) with /churn p99 308 ms — wrong fix if live heap does not fit.
Optional next labs (not blockers): Grafana screenshots; host vmstat si/so strip; automemlimit vs manual env; supersede any CMS Swap/Go GC stub carefully at paste time.
Q1. Is GOMEMLIMIT a substitute for a container memory limit?
No. Set the cgroup/VPS limit as the hard ceiling; use GOMEMLIMIT so the Go GC cooperates inside that ceiling.
Q2. Should I always disable swap on app VPSes?
Often yes for latency-sensitive services, if you size RAM and limits correctly and alert on OOM. Keep swap only if you consciously accept thrash risk and monitor si/so.
Q3. Can GOMEMLIMIT alone cause thrashing?
If the live heap cannot fit under the limit, GC runs constantly and progress stalls. The runtime softens the limit to avoid infinite thrash, but a mis-set limit still hurts. Prefer raising RAM or cutting live set.
Q4. Where do I put GOMEMLIMIT — systemd, Docker, or code?
Any of the three. Env on the unit/container is simplest to audit. SetMemoryLimit helps when limits change at runtime (e.g. cgo spike). Document one source of truth.
Q5. How is this different from JVM -Xmx on 512 MB?
Different runtime, same operational lesson: the heap knob is not the whole process. See the JVM heap vs RSS draft and the existing Boot vs Quarkus post.
Q6. Will raising GOMAXPROCS fix thrashing?
No. More Ps can increase parallelism and allocator pressure. Cap GOMAXPROCS to your CPU quota separately.
Lab evidence
Cgroup v2 lab 29 Sep 2026 IST. Go 1.24.4 lab API. Phase A 512MiB retain420 → OOM + swap peak 256MiB. Phase E 384MiB+swap alive thrash: /churn p99 398ms. Phase B GOMEMLIMIT=400MiB survived (994 GC). Phase D limit+swap.max=0: /json p99 1.3ms, 20 GC cycles. Policy: prefer fail-fast swap.max=0 once live set fits under GOMEMLIMIT.
Summary29 Sept 202611 min read
Hands-on6 Oct 20268 min read
Hands-on gc.collect cost empty vs cyclic garbage lab: real collect latency plus reclaim counts, measured on Linux localhost today in this lab for SREs.
Hands-on1 Oct 20264 min read