Skip to content
gohacks.dev
Performance

Go Memory Usage Looks High and Why RSS Can Mislead You

Why a Go program shows 500 MB of RSS after freeing it all: HeapIdle held 515 MB, FreeOSMemory returned it on Linux but not on macOS, and how to read both

7 min read
Summarize in AI
Flat illustration of a flame graph with one hot path.

A Go service is reported as using 600 MB, the heap profile says 80 MB is live, and someone opens an incident for a memory leak. Often there is no leak. Three different numbers all get called "memory" and they measure different things: what your code holds, what the Go runtime holds, and what the operating system has resident for the process. This article allocates 512 MB, drops all of it, and prints each number at every stage, on macOS and on Linux, to show where the difference lives and which number to trust for which question.

Tested with Go 1.25.6 on macOS arm64 (Apple M1 Max) and golang:1.25 on Linux arm64 in Docker, 512 MB allocated then dropped

It sits between GOMEMLIMIT and memory leaks that are not goroutines. To find who holds live memory use heap profiles. The cornerstone is the Go performance guide.

Four numbers called memory

runtime.MemStats exposes the runtime's view. The fields that matter here:

  • HeapAlloc: bytes of live heap objects, plus garbage not yet collected. This is what your code holds.
  • HeapInuse: bytes in spans that contain at least one object. It is larger than HeapAlloc when spans are only partly used, which is fragmentation.
  • HeapIdle: bytes in spans with no objects. The runtime owns them and can reuse them for new allocations without asking the kernel.
  • HeapReleased: the part of HeapIdle that the runtime has told the operating system it no longer needs.
  • Sys: the total the runtime has obtained from the operating system, for the heap, stacks and its own structures.

The fifth number is not in MemStats. RSS, the resident set size, is the memory the kernel currently keeps in RAM for the process. It is what ps, top, container metrics and the kernel's out of memory killer look at. The runtime can only influence it by telling the kernel which pages it no longer needs.

The experiment

The program allocates 512 MB in 1 MB slices and touches one byte per page so the kernel really maps them, drops the slices, runs runtime.GC(), then calls debug.FreeOSMemory(), then allocates 64 MB again. At each stage it prints the MemStats fields and the RSS from ps. The script also runs it with GODEBUG=madvdontneed=1.

main.goGo
package main

import (
	"fmt"
	"os"
	"os/exec"
	"runtime"
	"runtime/debug"
	"strconv"
	"strings"
)

var keep [][]byte

// rss asks the operating system, because MemStats cannot see what the kernel counts.
func rss() int {
	out, err := exec.Command("ps", "-o", "rss=", "-p", strconv.Itoa(os.Getpid())).Output()
	if err != nil {
		return -1
	}
	kb, _ := strconv.Atoi(strings.TrimSpace(string(out)))
	return kb / 1024
}

func report(stage string) {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("%-34s HeapAlloc %4d  HeapInuse %4d  HeapIdle %4d  HeapReleased %4d  Sys %4d  RSS %4d (MB)\n",
		stage, m.HeapAlloc>>20, m.HeapInuse>>20, m.HeapIdle>>20, m.HeapReleased>>20, m.Sys>>20, rss())
}

func main() {
	report("start")
	for i := 0; i < 512; i++ {
		b := make([]byte, 1<<20)
		for j := 0; j < len(b); j += 4096 {
			b[j] = 1
		}
		keep = append(keep, b)
	}
	report("512 MB allocated and touched")
	keep = nil
	runtime.GC()
	report("dropped, after runtime.GC")
	debug.FreeOSMemory()
	report("after debug.FreeOSMemory")
	b := make([]byte, 64<<20)
	for j := 0; j < len(b); j += 4096 {
		b[j] = 1
	}
	runtime.KeepAlive(b)
	report("then 64 MB live again")
}
Shell
set -e
go build -o app .
./app
echo
echo "the same program, with the runtime returning memory immediately (GODEBUG=madvdontneed=1)"
GODEBUG=madvdontneed=1 ./app
rm -f app
Text
start                              HeapAlloc    0  HeapInuse    0  HeapIdle    3  HeapReleased    3  Sys    7  RSS    4 (MB)
512 MB allocated and touched       HeapAlloc  512  HeapInuse  512  HeapIdle    3  HeapReleased    2  Sys  520  RSS  517 (MB)
dropped, after runtime.GC          HeapAlloc    0  HeapInuse    0  HeapIdle  515  HeapReleased    2  Sys  520  RSS  517 (MB)
after debug.FreeOSMemory           HeapAlloc    0  HeapInuse    0  HeapIdle  515  HeapReleased  514  Sys  520  RSS  518 (MB)
then 64 MB live again              HeapAlloc   64  HeapInuse   64  HeapIdle  451  HeapReleased  450  Sys  520  RSS  518 (MB)

the same program, with the runtime returning memory immediately (GODEBUG=madvdontneed=1)
start                              HeapAlloc    0  HeapInuse    0  HeapIdle    3  HeapReleased    3  Sys    7  RSS    4 (MB)
512 MB allocated and touched       HeapAlloc  512  HeapInuse  512  HeapIdle    3  HeapReleased    2  Sys  520  RSS  517 (MB)
dropped, after runtime.GC          HeapAlloc    0  HeapInuse    0  HeapIdle  515  HeapReleased    2  Sys  520  RSS  517 (MB)
after debug.FreeOSMemory           HeapAlloc    0  HeapInuse    0  HeapIdle  515  HeapReleased  513  Sys  520  RSS  518 (MB)
then 64 MB live again              HeapAlloc   64  HeapInuse   64  HeapIdle  451  HeapReleased  450  Sys  520  RSS  518 (MB)

Results on macOS

StageHeapAllocHeapInuseHeapIdleHeapReleasedSysRSS
start003374
512 MB allocated and touched51251232520517
dropped, after runtime.GC()005152520517
after debug.FreeOSMemory()00515514520518
64 MB live again6464451450520518

(all in MB)

Dropping data does not lower RSS. After the collection HeapAlloc was 0, which is the right answer to "how much does my code hold", and RSS was still 517 MB. The 515 MB moved from HeapInuse to HeapIdle. The runtime keeps the pages so that the next allocation does not need a system call and a page fault. That is the heap doing its job, and it is the reason a service that peaked at 600 MB looks like it uses 600 MB all day.

FreeOSMemory raised HeapReleased and did nothing to RSS on macOS. The runtime did tell the kernel, and the HeapReleased figure jumped to 514 MB. The kernel on macOS marks those pages reclaimable but does not take them away until something else needs the memory, so ps still showed 518 MB. This is a platform difference and an honest limitation of the measurement: on a laptop RSS is a poor indicator of what the program holds.

Results on Linux

The same source built with the golang:1.25 image and run in a container (arm64 Linux) printed:

StageHeapAllocHeapIdleHeapReleasedRSS
512 MB allocated and touched51232516
dropped, after runtime.GC()05152516
after debug.FreeOSMemory()05155153
64 MB live again6445145067
Shell
docker run --rm -v "$PWD":/src:ro -w /tmp golang:1.25 bash -c \
  'mkdir w && cp /src/main.go w/ && cd w && go mod init lab && go build -o /tmp/app . && /tmp/app'

On Linux the story is the textbook one. RSS stayed at 516 MB after the collection and fell to 3 MB after FreeOSMemory, because the runtime returns pages with MADV_DONTNEED and the kernel drops them at once. Allocating 64 MB afterwards took RSS to 67 MB: released memory is faulted back in on demand, which is why returning memory too eagerly costs page faults later. Setting GODEBUG=madvdontneed=1 gave the same result as the default here, since Go 1.16 uses MADV_DONTNEED on Linux already.

Why the runtime does not return memory at once

The runtime keeps freed spans for reuse and returns them to the kernel in the background. A scavenger goroutine releases idle memory slowly, aiming to keep total retained memory a little above what the heap goal needs, and it returns memory faster when a memory limit is close (GOMEMLIMIT). That trade is intentional. A server whose traffic goes up and down every minute would pay for a system call and a page fault on every allocation if it returned memory eagerly.

You can see the scavenger's effect over time. Run the lab, drop the data and watch RSS on Linux without calling FreeOSMemory: it falls over minutes, not at once. Calling debug.FreeOSMemory() forces a collection and a full release. It is meant for tools that know their peak has passed (after loading a big file, after a batch), not for request paths, because it stops the world for a collection and makes the next allocations fault.

A decision table

You seeIt usually meansCheck
High RSS, low HeapAllocidle memory not yet returnedHeapIdle and HeapReleased
High RSS, high HeapAllocthe program holds it, or the collector has not runheap profile; live heap after GC
HeapInuse far above HeapAllocfragmentation across partly used spansallocation size mix
RSS above Sysmemory outside the runtime: cgo, mapped filespmap, cgo libraries
RSS rising with HeapAlloc after GCa real leakheap profile diff

The last row is the only one that needs a fix in the code. The rest need an explanation, and sometimes a runtime setting.

What to do about it

  1. Alert on the live heap after collection, /gc/heap/live:bytes, and on RSS separately. Alerting on RSS alone fires after every traffic peak.
  2. Set GOMEMLIMIT below the container limit so that the scavenger works harder when memory is tight (GOMEMLIMIT).
  3. Compare heap profiles taken at two times with go tool pprof -base; a leak shows as a type whose in-use bytes keep growing (heap profiles).
  4. Do not call FreeOSMemory in a loop to make a graph look better. It trades RSS for page faults and latency.
  5. Remember the platform. A development laptop running macOS will show RSS that never shrinks. Take the measurement on the OS you deploy to.

Checklist

  • HeapAlloc is held by the program; HeapIdle is held by the runtime; RSS is held by the kernel's accounting.
  • Idle memory is retained on purpose and returned gradually by the scavenger.
  • FreeOSMemory released 515 MB on Linux and showed no RSS change on macOS.
  • Judge leaks by the live heap after collection, not by RSS.
  • Measure on the operating system the service runs on.

The fields are documented in the runtime.MemStats reference and the scavenger in the Go GC guide.

Also inTooling
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.