Reading contract: do not treat “heap” as a bad word. First separate three questions: why must an object outlive the current function, how many new objects does the program create each second, and how many remain alive when GC runs? The second half enters escape analysis, allocation, and concurrent GC source.

The language does not promise that a value lives on a stack or heap. Concrete decisions are pinned to the Go 1.26.0 tag: escape.go, malloc.go, mgc.go, and mgcpacer.go. Measurement contracts come from the official runtime/metrics, runtime/pprof, and Diagnostics documentation.

1. An Object on the Heap Is Not Automatically a Problem

In fetchd, a local value used only for one calculation and discarded when the function returns can often live on the goroutine's stack. A result that is returned, handed to another goroutine through a channel, or retained in a slice may need to outlive the current call. Putting it on the heap preserves the correct lifetime; it is not a compiler failure.

Three different measurements determine whether there is a problem: allocation rate is how quickly requests create new objects; the live set is how much remains reachable when GC runs; and latency cost is how much allocation or GC work lands on a request. Reducing allocation has a clear purpose only when one of those measurements conflicts with the system's goals.

QuestionFirst evidenceWhat it cannot prove alone
Why is it on the heap?-gcflags='-m=2' plus ownershipWhether optimization is worthwhile
How much does one path allocate?Benchmark plus -benchmemWho survives in production
Who retains the heap?Heap profile, in-use viewHistorical allocation traffic
Who creates allocation traffic?Allocs profile, cumulative viewThe current live heap
Does GC affect latency?Metrics, gctrace, execution traceWhere one object should live

2. Ask “How Long Must It Live?” before Stack or Heap

The Go specification describes values, addresses, and observable behavior; it does not require a syntax form to live on the stack or heap. The compiler's escape package states two invariants: pointers to stack objects cannot be stored in the heap, and pointers cannot outlive the stack objects they reference. If the compiler can prove an address stays within a frame's lifetime, even new(T) may stay on the stack. A plain local can instead move to the heap when its address is returned, captured, or stored in a longer-lived object.

//go:noinline
func stackResultChecksum() int64 {
    result := fetchResult{
        URL: "https://go.dev", Status: 200, Bytes: 42,
    }
    return result.Bytes + int64(len(result.URL))
}

//go:noinline
func escapingResult() *fetchResult {
    return &fetchResult{
        URL: "https://go.dev", Status: 200, Bytes: 42,
    }
}

“Returning a pointer is always slow” is not a stable rule either. Inlining, interprocedural propagation, and later compiler versions can change the result. The lab uses //go:noinline to make its shape explicit, not as a production recommendation. Read the report for the actual build, then verify allocation counts.

2.1 The Compiler Builds a Weighted Location Graph

Escape analysis maps variables and implicit allocations to locations, then turns assignments into directed edges. An edge's weight is dereference count minus address count: p = &q is -1, p = q is 0, and p = *q is +1. It then walks paths that could violate the two invariants. If an address reaches the heap or another location that may outlive it, its location requires heap allocation. Interprocedural flows are summarized as parameter tags for static call sites.

Go escape analysis as a weighted location graph: taking an address has weight minus one, assignment zero, and dereference plus one; returning an address or publishing a slice backing array can connect result to the heap and produce compiler escape reports
Invariants, weights, and algorithm: escape.go L19-L89; batch entry: Batch.

The graph is a conservative approximation. The source explicitly says it generally lacks full flow, path, and context sensitivity, and does not distinguish separate struct fields or slice elements. “Escapes to heap” means the compiler lacks a sufficient proof for the current frame. It does not mean the object leaks or that GC is currently under pressure.

2.2 Read -m=2 as a Flow Report

Across the full fetchd build report, memory_lab_test.go includes &fetchResult{...} escapes to heap and an escaping backing array for make([]fetchResult, 0, count) when the capacity is dynamic. The real handler's batch collector also reports append escapes to heap. The point is not to count the word “heap.” Explain the owner and longest lifetime: a pointer leaves the function, a returned slice owns its backing array, and append may publish a replacement array.

cd go-runtime/examples/fetchd
go test -run '^$' -gcflags='-m=2' ./...

Interface boxing, closure capture, and logging arguments may add more reports. Some matter in a hot path; some are initialization noise. Filter by the relevant call chain and align the report with allocs/op. If analysis is conservative but a robust benchmark observes zero allocations, inspect inlining, constant folding, and dead-code elimination before declaring either result contradictory.

2.3 Slice Preallocation Avoids Backing-Array Migrations

A slice header is a pointer, length, and capacity. Preallocation does not “move the header to the heap”; it lets a known upper bound obtain enough backing storage once. Repeated appends past capacity call growslice, allocate a larger array, and copy old elements. The growth policy is a current runtime implementation detail, not a business invariant that capacity always doubles.

// growth path
var results []fetchResult
for i := 0; i < count; i++ {
    results = append(results, fetchResult{N: i})
}

// one backing allocation for a known upper bound
results := make([]fetchResult, 0, count)
for i := 0; i < count; i++ {
    results = append(results, fetchResult{N: i})
}

For 64 results, this lab consistently measured 7 allocs/op and about 9.1 KiB/op for incremental growth, versus 1 alloc/op and about 4.8 KiB/op with preallocation. The escaping pointer path used 1 alloc/op and 64 B/op; the checksum-only value path used 0 alloc/op. Those numbers describe this shape, not a cross-platform constant. Excess preallocation can enlarge the live heap, so use a trustworthy bound rather than “as large as possible.”

3. mallocgc Routes by Size and Pointer Shape

Keep following the same batch of 64 results:
append runs out of capacity → request a larger backing array → copy old results
without preallocation, this lab allocates seven times
each request eventually enters the allocator
the accumulated new memory reaches the next GC heap goal sooner

Separate two layers: growslice explains why the application needs another block, while mallocgc explains where the runtime obtains it. Raising GOGC may delay the next collection, but it does not erase those seven backing-array requests. Reducing allocations and changing collection frequency are different actions.

Go 1.26.0 first short-circuits a zero-sized request to shared zerobase. On the regular path, small objects are roughly at most 32 KiB. Pointer-free objects below 16 B may enter the tiny allocator, packing multiple values into one 16 B block. Other small objects split by size class and scan/noscan shape; large objects allocate directly from heap pages. Pointer-bearing objects also need correct bitmap and zeroing state so the collector knows which words to scan.

Go mallocgc allocation paths: zero-sized allocations use zerobase, tiny pointer-free values share a sixteen-byte block, small scan and noscan objects use size classes and per-P mcache spans, full spans refill through mcentral, and large values use mheap pages
if size == 0 {
    return unsafe.Pointer(&zerobase)
}
if gcBlackenEnabled != 0 {
    deductAssistCredit(size)
}
// small noscan / small scan / large routing follows
Entry and routing: mallocgc. Go 1.26 also contains size-specialized paths guarded by experiment and sanitizer conditions; applications must not depend on that internal branch.

Size classes make fixed-sized slots cheap to manage, while introducing internal fragmentation of elemsize - requested size. Tiny packing has another tradeoff: if any object in a 16 B block remains reachable, the entire block stays live, so this applies only to noscan objects. Applications control object shape, batch size, and reuse boundaries—not runtime spans directly.

3.1 The Fast Path Is the Current P's mcache and mspan

nextFreeFast finds a trailing zero in the current span's allocCache, advances freeindex, and computes the slot address. It does not visit the global heap for every object. When a cached span fills, mcache.nextFree calls refill. Refill returns the full span to the central list and obtains a span with space from mcentral.cacheSpan. The source requires a non-preemptible context because the cache owner is tied to P and could change after preemption.

func nextFreeFast(s *mspan) gclinkptr {
    theBit := sys.TrailingZeros64(s.allocCache)
    if theBit < 64 {
        result := s.freeindex + uint16(theBit)
        // advance cache and return s.base + result*s.elemsize
    }
    return 0
}
nextFreeFast and mcache.nextFree; mcache.refill.

“Small allocation is fast” describes the common fast path, not a zero-cost system. Refill can entail sweeping and a GC trigger check; zeroing and sanitizers add work; most importantly, cumulative allocation advances the heap goal. A nanosecond-scale allocation repeated at high QPS can still turn substantial CPU into marking and sweeping.

4. GC Performs Concurrent Marking between Two Brief Stops

First picture GC as an inventory of results that somebody still holds. Starting from roots such as goroutine stacks and globals, an object reachable by following pointers must stay; one unreachable from every root may be swept. Source uses colors for progress: white means not yet confirmed, grey means found but its outgoing pointers still need inspection, and black means fully inspected. These are states in one marking cycle, not permanent properties of an object.

The runtime's own overview is explicit. It completes sweep termination under a brief stop, enters _GCmark, prepares root jobs, enables the write barrier and mark workers, then resumes mutators for concurrent marking. Once the mark-completion condition holds, another stop enters _GCmarktermination for final work and accounting. The runtime returns to _GCoff, disables the barrier, and sweeps concurrently.

Go concurrent GC: a heap goal is constrained by GOGC and the soft memory-limit goal; a brief stop precedes concurrent marking by background workers, hybrid write barriers, and allocation assists; a second brief mark-termination stop leads to concurrent sweep and reuse

“Go GC is concurrent” and “Go GC has STW” are both incomplete. Examine pauses, concurrent CPU, assist charged to the request, and changes in live heap together. Pause quantiles alone can miss tail latency from request goroutines doing GC work; GC CPU alone can mislabel an application allocation spike as a collector regression.

Phases and full cycle: mgc.go overview; gcStart.

4.1 The Hybrid Write Barrier Keeps White Objects Visible

Applications still mutate heap pointers during concurrent marking. Go's hybrid write barrier shades the old object in a slot before publishing the replacement. While the current goroutine's stack is grey, it also shades the new pointer. A mutator therefore cannot hide the only reference by moving it among heap and scanned or unscanned stacks. The compiler can omit barriers for writes to the current frame, while heap publication obeys the pre-publication rule.

writePointer(slot, ptr):
    shade(*slot)
    if current stack is grey:
        shade(ptr)
    *slot = ptr
Algorithm, correctness argument, and omission rules: runtime/mbarrier.go.

This helps explain why pointer-rich objects and pointer-free buffers have different scanning costs. Do not smuggle a pointer into uintptr to “avoid scanning”; that breaks reachability and lifetime rules. Improve scan volume with compact structures, smaller accidental object graphs, and shorter ownership instead.

4.2 If Allocation Outruns Marking, the User Goroutine Assists

Concurrent marking is not a free background service. While GC is active, mallocgc deducts assist credit in proportion to allocation size. When a goroutine's debt becomes negative, gcAssistAlloc first tries to use global background scan credit; if that is insufficient, it scans object graph work directly. This connects the producer of allocation pressure to part of the marking bill and helps marking finish before the heap goal.

allocation
  → deductAssistCredit(size)
  → assist debt below zero
  → steal background scan credit or gcAssistAlloc1
  → goroutine pays marking work before continuing
gcAssistAlloc and gcAssistAlloc1.

GC assists on allocation-heavy goroutines in an execution trace are more actionable than “GC occasionally jitters.” First reduce avoidable allocation bytes on the request path; only then tune GOGC. Otherwise the tuning changes when the bill is paid without changing the bill.

4.3 GOGC and GOMEMLIMIT Constrain Different Things

GOGC scales the next heap goal from the previous live heap. At the default 100, a useful approximation is that new allocation may approach the current live heap before the next cycle must finish. GOMEMLIMIT gives the runtime a soft memory limit. The controller derives a memory-limit heap goal from runtime-managed mapped memory and reserves headroom. The effective target uses the tighter GOGC-derived or memory-limit-derived goal.

ControlMain tradeoffWrong interpretation
Lower GOGCSmaller heap, more GC CPUAutomatically lowers business allocations
Higher GOGCLarger heap, usually fewer cyclesRemoves memory-limit risk
GOMEMLIMITMore collection pressure to defend a soft targetA hard heap cap or exact container limit
BothProportional and environmental goals competeGOGC stops mattering once a limit is set

A soft limit is not an OOM guarantee. Non-Go memory, mappings, cgo, kernel page cache, and container accounting can use different scopes. Under an extreme limit the runtime must also avoid GC thrashing that starves the application. Production settings need load tests across container headroom, live set, peak traffic, CPU budget, and tail latency.

Pacer goals, 25% background utilization, and memory-limit headroom: mgcpacer.go; configuration contracts: SetGCPercent and SetMemoryLimit.

5. Observe Allocation Rate Separately from Live Heap

The lab reads /gc/heap/allocs:bytes, /gc/heap/allocs:objects, /gc/heap/live:bytes, and /gc/cycles/total:gc-cycles through runtime/metrics. The first two are cumulative allocation, live bytes are the objects confirmed live by the previous GC, and cycles describe collection cadence. Their definitions and sampling points differ; allocs minus live is not a precise reclaimed-byte counter.

samples := []metrics.Sample{
    {Name: "/gc/heap/allocs:bytes"},
    {Name: "/gc/heap/allocs:objects"},
    {Name: "/gc/heap/live:bytes"},
    {Name: "/gc/cycles/total:gc-cycles"},
}
metrics.Read(samples)
ToolBest questionBoundary
go test -benchmemB/op and allocs/op for a controlled pathNot the production traffic distribution
Heap profileWho still retains memory at the sampleDefault view is in-use, not all history
Allocs profileWho cumulatively created allocation trafficHigh cumulative volume is not a leak
runtime/metricsService trends and alertsRead metric description and kind first
GODEBUG=gctrace=1Per-process GC cycle summariesTemporary diagnosis, not tracing
Execution traceAssist, STW, and goroutine timingBound the collection window
cd go-runtime/examples/fetchd
go test -run 'Test(AllocationShape|RuntimeMetrics)' -count=20
go test -bench BenchmarkAllocationShapes -benchmem -count=3
go test ./...
go test -race ./...
go vet ./...

6. A Memory Checklist for fetchd

  1. Define owner and lifetime first. Keep request temporaries inside the handler; explicitly transfer truly asynchronous data to a process or session owner.
  2. Use reports to explain flows and benchmarks to judge value. -m=2 is not a performance ranking.
  3. Preallocate from a credible bound. Avoid repeated backing-array migration without trusting unbounded input.
  4. Reducing bytes may matter more than reducing object count alone. Measure both alongside CPU and latency.
  5. Separate allocation rate from live heap. The former creates collection work; the latter sets the graph that must remain scanned.
  6. Put mark assist in the request latency model. GC CPU does not belong only to background workers.
  7. Load-test GOGC and GOMEMLIMIT headroom. Record container RSS, runtime memory, GC CPU, and tail latency together.
  8. Do not depend on allocator internals. Size classes, tiny packing, and specialized malloc can change with versions and experiments.

The reusable conclusion is: escape analysis decides whether an object can safely stay in a frame; allocation rate determines how much collection work the application creates; live heap determines the object graph the collector must continue to maintain. The final chapter returns this request to the network: connection reuse, HTTP/2 concurrency, and a joint diagnosis with httptrace, pprof, trace, metrics, and race.

Source and documentation map