Plate 27
Go GC + Swap on a Tiny VPS: Stop Thrashing Before the OOM Killer
Aditya Challa13 min read
On this page
- Intro — what this post promises
- Why a “healthy” Go process can still be dying slowly
- The soft memory limit is not a hard cgroup ceiling
- `GOGC` alone does not know your box size
- Swap turns OOM into a latency incident
- Symptoms checklist — thrashing vs OOM vs “just busy”
- What to measure before changing knobs
- Host / cgroup layer
- Go runtime layer
- Application latency layer
- The playbook — stop thrashing before the OOM killer
- Step 1 — Decide: thrash-prone or fail-fast?
- Step 2 — Set `GOMEMLIMIT` with headroom
- Step 3 — Revisit `GOGC` only after the limit is sane
- Step 4 — Reduce allocation rate (the real long-term fix)
- Step 5 — Alert on the right symptoms
- Honest advantages and disadvantages
- Worked example (measured lab — 29 Sep 2026 IST)
- External citations
- FAQ
- CTAs
Intro — what this post promises
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:
- How Go’s
GOGCand softGOMEMLIMITinteract with Linux memory and swap. - Symptoms that distinguish thrashing from a clean OOM kill.
- What to measure (cgroup, swap, GC, latency) before you touch knobs.
- A concrete checklist you can run on your own box.
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.
Why a “healthy” Go process can still be dying slowly
The soft memory limit is not a hard cgroup ceiling
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:
- The limit is soft. Under pressure the GC will try harder, but Go will still allocate past the limit rather than thrash forever at 100% GC CPU (there is a ~50% GC CPU safeguard described in the GC guide).
- Non-Go RSS still counts against your cgroup / VPS RAM. Leaving 5–10% headroom (official rule of thumb in the GC guide for containerised services) is not optional folklore — it is how you avoid “GOMEMLIMIT says fine, kernel says OOM.”
GOGC alone does not know your box size
GOGC 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.”
Swap turns OOM into a latency incident
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:
- Every allocation / GC scan that touches swapped pages pays disk latency.
- GC runs more often as the soft limit or heap target fights reality.
- p99 latency explodes while average CPU looks “utilized.”
That is thrashing: the system is busy moving pages, not serving users.
Symptoms checklist — thrashing vs OOM vs “just busy”
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.
What to measure before changing knobs
Host / cgroup layer
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.
Go runtime layer
Useful starting points from the GC guide diagnostics section:
- CPU profile: watch cumulative time in
runtime.gcBgMarkWorker,runtime.mallocgc,runtime.gcAssistAlloc. GODEBUG=gctrace=1for per-cycle lines (stderr).runtime/metricsor expvar//debug/varsif 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.
Application latency layer
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:
- https://www.shoppercove.com/blog/read-server-monitoring-graphs-2
- https://www.shoppercove.com/blog/spring-boot-vs-quarkus-512-mb-vps
The playbook — stop thrashing before the OOM killer
Step 1 — Decide: thrash-prone or fail-fast?
| 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).
Step 2 — Set GOMEMLIMIT with headroom
From the official GC guide suggested uses:
- Do set a memory limit when the Go program owns a reservation (container / dedicated VPS slice).
- Leave ~5–10% headroom for memory Go does not track.
- Don’t set a limit merely to “avoid OOM” when the process is already near the ceiling — that can trade OOM for severe slowdown (thrashing mitigation has limits).
- Don’t bake a low limit into a CLI that shares a laptop with unknown inputs.
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).
Step 3 — Revisit GOGC only after the limit is sane
Pattern that often works for services:
- Set
GOMEMLIMITto a soft cap under the cgroup. - Keep or raise
GOGCso the average case is economical; let the limit clip spikes. - Only lower
GOGCif 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.
Step 4 — Reduce allocation rate (the real long-term fix)
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).
Step 5 — Alert on the right symptoms
Minimum alert set for tiny VPS Go services:
- Swap-in rate > 0 sustained for N minutes
- cgroup
memory.eventsoom/oom_kill - GC CPU fraction or assist time above budget
- Latency p99 (not average) SLO burn
- Restart count / OOM kills
Honest advantages and disadvantages
| 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.
Worked example (measured lab — 29 Sep 2026 IST)
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.
External citations
- https://go.dev/doc/gc-guide
- https://pkg.go.dev/runtime
- https://pkg.go.dev/runtime/debug#SetMemoryLimit
- https://github.com/KimMachineGun/automemlimit
FAQ
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.
CTAs
- Subscribe via RSS: https://www.shoppercove.com/feed.xml
- About the author / method: https://www.shoppercove.com/about
Lab evidence
What I found running this
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.
Related links
Plate 63
JVM Heap vs RSS vs OOM on 512 MB: What `-Xmx` Does Not Tell You
29 Sept 2026
Plate 68
gc.collect Cost Empty vs Cycles: Localhost Lab
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.
Observability & SRE · 1 Oct 2026
Plate 12
ChainMap vs Merged Dict Lookup: Localhost Lab
Hands-on collections.ChainMap vs merged dict config lookup: real ops/s on layered keys, measured on Linux localhost today in this hands-on lab for SREs.
1 Oct 2026