Stack vs Heap
Where your variables actually live: call frames that vanish for free, heap objects someone has to clean up, how Go's escape analysis decides, what JavaScript does instead — and when the difference shows up in your latency graphs.
The 30-second version
Every variable your program creates has to live somewhere in memory. There are two main places: the stack and the heap. Knowing which one a value lands in explains a surprising amount: why some code is many times faster than nearly identical code, why recursion can crash, and why the garbage collector sometimes eats your latency budget.
Stack: automatic
Heap: outlives the call
Who decides?
Here is the difference in a single measurement. Two Go functions build the same three-number struct; one returns it by value, the other returns a pointer to it:
type Point struct{ X, Y, Z float64 }
//go:noinline
func newPointVal(x float64) Point { return Point{x, x, x} } // stays on the stack
//go:noinline
func newPointPtr(x float64) *Point { return &Point{x, x, x} } // goes to the heapBenchmarkPointPtr-4 69141230 18.26 ns/op 24 B/op 1 allocs/op
BenchmarkPointVal-4 1000000000 1.000 ns/op 0 B/op 0 allocs/opAbout 18× slower — and the only difference is where the struct lives. (In real code the gap is usually smaller, because real functions do more work. But the direction never changes.) The rest of this page explains why, how to see which one you're getting, and when it's worth caring.
The call stack: a pile of frames
When your program calls a function, the computer needs somewhere to keep that call's working data: the arguments it was given, its local variables, and a note saying where to go back to when it's done. That bundle is a Stack frame. Frames are kept in one block of memory, stacked one on top of the other.
What's in a frame
Arguments and locals: the variables this call owns. Every call gets its own copy, which is why recursion works.
Return address: where to continue in the caller once this call finishes.
Stack pointer: a single CPU slot that says where the top of the pile is.
Two of those words are new:
- The Return address is saved when the call starts: "when you're done, continue at line 4 of
handleRequest". Returning means jumping there. - The Stack pointer lives in a CPU register — one of the few tiny storage slots inside the CPU itself. It holds the address of the top of the pile.
Why the stack is (nearly) free
Pushing a frame = moving the stack pointer by the frame's size. Popping = moving it back. That's one arithmetic instruction. There is no searching for space, no bookkeeping, no cleanup crew. Whatever was in the popped frame is simply overwritten by the next call.
This works because frames are created and destroyed in strict last in, first out order: a function always returns before its caller does. The compiler also knows each function's frame size in advance, so the push is a single subtraction or addition.
Watch it happen. The function below computes a factorial (3! = 3 × 2 × 1) by calling itself — that's Recursion:
1function fact(n) {2 if (n <= 1) return 1; // base case: stop here3 const rest = fact(n - 1); // call ourselves with a smaller n4 return n * rest;5}67console.log(fact(3));
fact(3).Go and JavaScript, same idea
Both languages use a call stack exactly like this. You've already seen it in every error message:
panic: boom
goroutine 1 [running]:
main.getUser(...)
/app/main.go:9 # ← top frame: where it happened
main.handleRequest(...)
/app/main.go:4 # ← its caller
main.main()
/app/main.go:14 +0x25 # ← the bottom frameA stack trace is literally a printout of the frames on the call stack at the moment of the error, read from the top down. (Real output from Go 1.24 and Node 22, with paths shortened, the # notes added, and Node's internal frames trimmed.)
Stack overflow: when the pile hits the ceiling
In the sticky-note analogy, the pad has a fixed number of notes. Usually that's plenty: even a deep chain of function calls uses only a few dozen frames. The usual way to run out is a recursive function whose base case — the "stop here" condition — is missing or never reached.
Flip back to the stepper from §02 with the base case removed:
1function fact(n) {2 // ⚠ base case forgotten!3 const rest = fact(n - 1); // calls itself forever4 return n * rest;5}67console.log(fact(3));
fact(3)…The real error messages
let depth = 0;
function countDown(n) {
depth++;
return countDown(n - 1) + 1; // oops: no base case
}
try {
countDown(10);
} catch (err) {
console.log(err.name + ": " + err.message);
console.log("depth reached:", depth);
}RangeError: Maximum call stack size exceeded
depth reached: 10463Both are real runs (Node 22, Go 1.24 on Linux). Two differences are worth noticing:
- JavaScript throws a normal error (
RangeError), sotry/catchcan catch it and the program can carry on. The depth (~10,000 frames here) depends on how big each frame is and on the engine's limit. - Go treats it as fatal. It's not a panic / recover, so
recover()can't catch it; the whole program exits with status 2. And notice how far it got first: the goroutine's stack had grown to 512 MB (the end address minus the start address:0xc040090000 − 0xc020090000=0x20000000= 536,870,912 bytes) before the next doubling would have crossed the 1 GB limit. In Go, a runaway recursion eats a lot of memory before it dies.
How big is a stack, really?
| Whose stack | Typical size | Can it grow? |
|---|---|---|
| An OS Thread on Linux (C, Java, Rust, …) | 8 MB (the default ulimit -s is 8192 KB) | No — fixed when the thread starts |
| JavaScript in Node / Chrome (V8) | ~1 MB (V8's --stack-size default is 984 KB) | No |
| A Go Goroutine | starts at 2 KB (Go 1.24) | Yes, by copying (§10) — up to 1 GB on 64-bit |
Those numbers explain a famous Go feature. A program with 10,000 OS threads would reserve 10,000 × 8 MB = 80 GB of Address space for stacks. 10,000 goroutines start with 10,000 × 2 KB = 20 MB. (We measured exactly that in §10.) That's a big part of why goroutines are cheap, as the Async/Await page explains.
Fixing it
// Walks a tree by recursion. A very deep
// tree (say, 100,000 nested folders)
// overflows the call stack.
function count(node) {
let n = 1;
for (const child of node.children) {
n += count(child);
}
return n;
}// Same walk, but the "to do" list is an
// ordinary array — on the heap, which is
// limited only by available memory.
function count(root) {
let n = 0;
const todo = [root];
while (todo.length > 0) {
const node = todo.pop();
n++;
todo.push(...node.children);
}
return n;
}The heap: a warehouse with a manager
The stack's rule — "your locals vanish when you return" — is exactly what you want most of the time. But some values must outlive the function that created them:
- a user object you create in
loadUser()and then keep in a cache for an hour; - a list that grows to a size you only find out while the program runs;
- data shared between two goroutines, or kept by a Closure that runs later.
None of those can sit in a frame that's about to be popped. They go on the heap, and the frame keeps only a Pointer to them: a small value holding the heap address — the warehouse ticket from §01.
How a heap allocation works
Getting a piece of heap memory is called an Memory allocation, and the code that hands it out is the Memory allocator. In C you call it yourself (malloc to allocate, free to give it back). In Go and JavaScript it's hidden behind ordinary syntax:
u := &User{Name: "Ada"} // & takes the address of a new User
buf := make([]byte, n) // a byte slice whose length is known only at run time
m := map[string]int{} // map data usually lives on the heap
// Go's compiler decides whether each one REALLY needs the heap (§06).The allocator is the warehouse manager. Each request means: find a free spot that's big enough, record that it's taken, and hand back its address. Try it — and keep an eye on what happens after you free a few objects:
Why the heap costs more than the stack
Four separate costs, from most to least obvious:
- Finding room. The stack just moves a pointer. The allocator has to look up a free spot and update its records. Real allocators are clever: Go's sorts small objects into fixed buckets called size classes and keeps a private cache per CPU, and V8 places brand-new objects with Bump allocation. That makes one allocation take tens of nanoseconds, not microseconds — but it's still far more than "move the stack pointer".
- Cleaning up later. When a heap value isn't needed any more, nothing happens immediately. The garbage collector has to come by later, figure out what is still reachable (can still be reached by following pointers from your variables), and free the rest. More allocations → more GC work → more CPU spent cleaning, and sometimes slower requests. How the GC does this is its own topic: Garbage collection.
- Spreading data out. Locals in the current frame sit next to each other in memory the CPU has just used, so they're almost always in the fast CPU cache. Heap objects can be scattered, and following a pointer to a cold spot costs a trip to RAM (~100 ns). See CPU caches.
- Fragmentation. After many allocate/free cycles, the free space gets chopped into small gaps (the widget above shows it). That's Heap fragmentation: lots of free bytes, but no gap big enough. Go's size classes keep this in check; V8's collector moves objects together to close the gaps.
- Move the stack pointer.
- (on return) Move it back. Done.
- Pick the right size bucket; find a free slot.
- Mark it taken; clear old bytes to zero.
- Hand back a pointer; store it somewhere.
- (later) GC finds everything still reachable from your variables…
- (later) …and frees the slot if nobody points to it.
Values, pointers and references
Where a value lives and how it gets copied are tied together. This section is about the second half: Value vs pointer semantics. Step through both languages below — the arrows are pointers, and the interesting moment is when a function returns:
1type Point struct{ X, Y int }23func main() {4 a := Point{1, 2} // a struct value5 b := a // b is a full COPY6 b.X = 99 // a.X is still 17 p := newPoint() // p holds an address8 p.X = 7 // changes the heap object9}1011//go:noinline12func newPoint() *Point {13 q := Point{3, 4}14 return &q // q's address leaves → heap15}
a is a struct value. All 16 bytes of it (two 8-byte ints) sit right inside main's frame on the stack. No heap involved.Go: you choose
In Go, a Struct is a value. Assigning it or passing it to a function copies every field. If you want the function to change the caller's struct, you pass a Pointer to it instead:
type User struct {
Name string
Age int
}
func birthdayValue(u User) { u.Age++ } // changes a copy
func birthdayPtr(u *User) { u.Age++ } // changes the caller's User
func main() {
ada := User{Name: "Ada", Age: 36}
birthdayValue(ada)
fmt.Println(ada.Age) // 36 — the copy got older, not Ada
birthdayPtr(&ada)
fmt.Println(ada.Age) // 37
}Notice that passing &ada down into birthdayPtr didn't force anything onto the heap: go build -gcflags=-m reports u does not escape for both functions, and ada stays in main's frame. A pointer only costs a heap allocation when the thing it points to has to outlive its frame (§06).
When pointers help, and when they hurt
// Small, read-only data passed as pointers
func distance(a, b *Point) float64 { … }
// Constructor returning a pointer to a
// 3-field struct the caller only reads
func NewRange(lo, hi int) *Range {
return &Range{lo, hi, hi - lo}
}// Small structs: just copy them
func distance(a, b Point) float64 { … }
// Return the value; the caller decides
// whether it needs a pointer
func NewRange(lo, hi int) Range {
return Range{lo, hi, hi - lo}
}Use a pointer when…
sync.Mutex (a Lock), which must never be copied.A pointer costs you…
Copying is cheap
JavaScript: the language chooses
JavaScript gives you no * or &. Instead, the kind of value decides:
- Primitives — numbers, strings, booleans,
null,undefined, bigints, symbols — behave like values.let m = ngivesmits own copy; nothing you do tomcan changen. - Everything else — objects, arrays, functions,
Maps, class instances — lives on the heap, and a variable holds a Reference to it.const b = acopies the reference, soaandbare two names for one object.
const a = { name: "Ada", tags: ["math"] };
const b = a; // same object
const c = { ...a }; // NEW object, but c.tags === a.tags (shallow)
const d = structuredClone(a); // NEW object, all the way down (deep)
b.name = "Grace";
console.log(a.name); // "Grace" — a and b are the same object
c.tags.push("poetry");
console.log(a.tags); // ["math", "poetry"] — the inner array was shared
console.log(d.tags); // ["math"] — a full, independent copyGo's escape analysis: who decides
The rule, and why it exists
A frame is wiped (reused) when its function returns. So if anything could still reach a variable afterwards — a pointer returned to the caller, stored in a global, sent to another goroutine, captured by a function that runs later — that variable can't live in the frame. The compiler finds all such cases while it builds your program and puts those variables on the heap instead.
That's why this Go code, which would be a serious bug in C, is perfectly safe:
func newPoint() *Point {
p := Point{1, 2}
return &p // in C: a pointer into a dead frame. In Go: p is moved to the heap.
}Asking the compiler
Add -gcflags=-m to go build (or go test, go run) and the compiler prints its decisions. For this program:
package main
type Point struct{ X, Y int }
func newPoint() *Point {
p := Point{1, 2}
return &p
}
func main() {
q := newPoint()
println(q.X)
}./main.go:5:6: can inline newPoint
./main.go:10:6: can inline main
./main.go:11:15: inlining call to newPoint
./main.go:6:2: moved to heap: pWait — "inlining call to newPoint" and "moved to heap: p"?
Inlining means the compiler copies a small function's body into its caller instead of making a real call. Once newPoint's body sits inside main, the "return" is just an assignment inside main's own frame — so the inlined copy of p stays on the stack. The line moved to heap: p is about the standalone, non-inlined copy of newPoint, which is still compiled for callers that can't inline it.
package main
type Point struct{ X, Y int }
//go:noinline
func newPoint() *Point {
p := Point{1, 2}
return &p
}
func main() {
q := newPoint()
println(q.X)
}$ go build -gcflags=-m .
./main.go:11:6: can inline main
./main.go:7:2: moved to heap: pnewPoint gets its own frame, which is popped when it returns, so p must be on the heap. q in main holds its address.We measured this with testing.AllocsPerRun (§08): calling the inlinable newPoint() made 0 allocations per call; the same function marked //go:noinline made 1. That's why small constructors returning pointers (NewX() *X) are often free — and why "it returns a pointer, so it allocates" isn't always true. Check -m; don't guess.
Here is -m=2 explaining itself for the standalone newPoint:
./main.go:6:2: p escapes to heap:
./main.go:6:2: flow: ~r0 = &p:
./main.go:6:2: from &p (address-of) at ./main.go:7:9
./main.go:6:2: from return &p (return) at ./main.go:7:2
./main.go:6:2: moved to heap: pRead it bottom-up as a chain of evidence: &p was taken (line 7), and it was returned (~r0 is the compiler's name for the unnamed result). Returned → may outlive the frame → heap.
Now you try
Nine small programs. Guess stack or heap before revealing the compiler's real answer. (-l turns inlining off so each function is judged on its own. The code is shown gofmt'd, with tab indentation — the compiler counts a tab as one column, which is how 6:2 lines up.)
go build -gcflags='-m -l' on Go 1.24.package main
type Point struct{ X, Y int }
func newPoint() Point {
p := Point{1, 2}
return p // a copy goes back to the caller
}
func main() { _ = newPoint() }p live?The common reasons a value escapes
| The value… | Example | Why |
|---|---|---|
| has its address returned | return &p | the caller outlives the frame |
| is stored somewhere longer-lived | global = &p, cache[k] = &p, a field of a heap object | that place outlives the frame |
| is captured by a closure that outlives the call | return func() { count++ } | the closure runs after the frame is gone |
| is sent on a channel (as a pointer) | ch <- &p | another goroutine, another stack |
| is converted to an interface the compiler can't follow | fmt.Println(n), var s Shape = r | the interface holds a pointer to a copy |
| has a size only known at run time | make([]int, n) | a frame's size is fixed at compile time |
| is too big | var buf [256 << 10]byte | stack variables have a size cap |
Two good habits come out of this:
- Don't try to read the compiler's mind. Ask it with
-gcflags=-m— on the hot path only — and confirm with-benchmem. - Escape analysis is per-version. Each Go release gets a bit smarter. For example, Go 1.25 and 1.26 report
make([]int, n) does not escapefor case 6 of the quiz, where Go 1.24 said it escapes (we checked with both toolchains). Re-check old assumptions after an upgrade.
JavaScript: (almost) everything object-like lives on the heap
& and no escape analysis you can see. Objects, arrays and closures go on V8's heap. V8 makes that cheap — and its optimizing compiler can sometimes delete a short-lived object entirely.What V8 keeps where
- Small integers (at least ±1 billion; the exact range depends on the build) are stored right in the variable slot. V8 calls them Smis.
- Objects, arrays, functions, strings live on the heap. The frame holds a Reference to each one.
- Other numbers (like
9.99) are usually put in a small heap box. This wrapping of a plain value in a heap object is called Boxing. (Inside optimized code and inside arrays of doubles, V8 often avoids the box.) - Variables captured by a Closure are moved out of the frame into a heap "context" object, because the closure may run after the function has returned. That's the same reason Go moves
countto the heap in the "closure returned" case of §06. Locals that no closure uses stay in the frame.
Allocation is cheap in V8 — and sometimes free
New objects are placed in a small "nursery" region of the heap using Bump allocation: move a pointer forward, done. When the nursery fills up, a quick minor GC (V8 calls it a Scavenge) copies the few survivors out and reuses the whole region. Most objects die young, so this is fast. (The details are on the Garbage collection page.)
Better still, V8's optimizing compiler, TurboFan, does its own escape analysis. TurboFan is a JIT compiler: it recompiles functions that run often. If it can prove a temporary object never leaves the optimized function, it keeps the object's fields in registers and never allocates the object at all.
You can watch this happen. This function creates a throwaway object on every call:
function dist(ax, ay, bx, by) {
const d = { x: ax - bx, y: ay - by }; // ← a fresh object every call
return Math.sqrt(d.x * d.x + d.y * d.y);
}
let total = 0;
const t0 = performance.now();
for (let i = 0; i < 20_000_000; i++) {
total += dist(i, i + 1, i + 2, i + 3);
}
console.log("ms:", Math.round(performance.now() - t0));node --trace-gc prints one line per garbage collection. We ran it three ways (Node 22, this machine):
| How we ran it | Minor GCs (Scavenges) | Time |
|---|---|---|
node --trace-gc churn.js (normal) | 2 | ~98 ms |
… --no-turbo-escape (TurboFan, its escape analysis off) | 777 | ~152 ms |
… --no-turbofan (no optimizing compiler at all) | 1,708 | ~1,490 ms |
[15145:0x19262000] 30 ms: Scavenge 4.5 (5.1) -> 3.6 (6.1) MB, pooled: 0 MB, 0.36 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;Twenty million objects were created in the source code, but with escape analysis on, almost none of them existed at run time. The JIT removed the allocation for you — something you could never do by hand in JS.
Keeping the JS heap happy
- Stable object shapes. V8 gives objects that have the same properties, added in the same order, a shared internal layout called a Hidden class. Create objects with all their properties at once (an object literal or a constructor) instead of adding properties later, and V8 can store them compactly and access them fast.
- Typed arrays for big numeric data.
new Float64Array(1_000_000)is one heap object holding raw 8-byte numbers. An array of a million{ x, y }objects is a million and one heap objects for the GC to trace. - Watch allocations in hot loops — but only after a profile tells you they matter (§08 and §11 show how to check).
Measuring allocation cost
-benchmem prints heap allocations per operation; Node's --trace-gc and process.memoryUsage() show the churn they cause.Go: a benchmark, before and after
Say every request builds a cache key like "user:123456". Here's the obvious version and an allocation-free one:
// Before: the obvious way.
func keySprintf(id int) string {
return fmt.Sprintf("user:%d", id)
}
// After: write into a buffer the caller owns and reuses.
func appendKey(buf []byte, id int) []byte {
buf = append(buf, "user:"...)
return strconv.AppendInt(buf, int64(id), 10) // writes the digits; no new string
}A Benchmark runs each one many times and reports the average:
func BenchmarkKeySprintf(b *testing.B) {
for b.Loop() {
keySprintf(123456)
}
}
func BenchmarkAppendKey(b *testing.B) {
buf := make([]byte, 0, 32) // allocated once, outside the loop
for b.Loop() {
buf = appendKey(buf[:0], 123456) // buf[:0]: same array, length 0
}
}The same file also holds the two Point benchmarks from §01:
type Point struct{ X, Y, Z float64 }
//go:noinline
func newPointPtr(x float64) *Point { return &Point{x, x, x} }
//go:noinline
func newPointVal(x float64) Point { return Point{x, x, x} }
func BenchmarkPointPtr(b *testing.B) {
for b.Loop() {
newPointPtr(1)
}
}
func BenchmarkPointVal(b *testing.B) {
for b.Loop() {
newPointVal(1)
}
}goos: linux
goarch: amd64
cpu: Intel(R) Xeon(R) Processor @ 2.30GHz
BenchmarkKeySprintf-4 16650057 66.45 ns/op 24 B/op 2 allocs/op
BenchmarkAppendKey-4 117536744 10.33 ns/op 0 B/op 0 allocs/op
BenchmarkPointPtr-4 69141230 18.26 ns/op 24 B/op 1 allocs/op
BenchmarkPointVal-4 1000000000 1.000 ns/op 0 B/op 0 allocs/opHow to read a row: the name (-4 means GOMAXPROCS was 4: Go could run code on 4 CPU cores at once), how many times it ran, time per call, bytes allocated per call, and heap allocations per call. Only heap allocations are counted — stack use is invisible here, because it's free.
Where do keySprintf's 2 allocations come from? One is the int being boxed into an any for Sprintf's ...any parameter (§06); the other is the result string (11 bytes, rounded up to a 16-byte Size class: 8 + 16 = 24 B/op). appendKey writes into memory that already exists, so it allocates nothing — ~6× faster here.
Node: watching the heap
JavaScript has no "allocs per call" counter, but you can watch the heap grow and shrink. process.memoryUsage() reports the heap right now:
const mb = (n) => (n / 1024 / 1024).toFixed(1) + " MB";
const show = (label) => {
const m = process.memoryUsage();
console.log(label.padEnd(26), "heapUsed", mb(m.heapUsed).padStart(9), " rss", mb(m.rss).padStart(9));
};
show("start");
const points = [];
for (let i = 0; i < 1_000_000; i++) {
points.push({ x: i, y: i }); // ← each object is kept: it lives on the heap
}
show("1M points kept");
points.length = 0; // drop every reference
globalThis.gc?.(); // only works with --expose-gc
show("after dropping + gc()");start heapUsed 3.5 MB rss 40.5 MB
1M points kept heapUsed 58.3 MB rss 139.8 MB
after dropping + gc() heapUsed 3.6 MB rss 123.3 MBheapUsedis the memory taken by live JS objects. A million small{ x, y }objects (plus the array holding them) took about 55 MB — roughly 55 bytes each, far more than the 16 bytes of actual numbers. Why: Node uses full 8-byte pointers, and each{ x, y }is 5 × 8 = 40 bytes (a pointer to its Hidden class, two slots for extra properties and elements, thenxandy). The array adds an 8-byte slot per object, plus spare room left over from growing.rssis all the memory the OS has given the process. Notice it did not drop back after the GC: V8 keeps the freed memory around for future allocations, and returns it to the OS lazily. That's normal. (RSS is explained on the Virtual memory page.)
For churn — lots of short-lived objects rather than a growing heap — count the minor GCs with node --trace-gc app.js | grep -c Scavenge, as we did in §07. Fewer scavenges for the same work means fewer allocations. For a real app, the Allocation sampling profile in Chrome DevTools (attach with node --inspect) shows which functions allocate the most.
Goroutine stacks: small, growable, movable
§03 showed the numbers: an OS thread reserves ~8 MB of stack, a goroutine starts with 2 KB (Go 1.24). Here's that measured — 10,000 goroutines, all waiting on a channel:
func stackKB() uint64 {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return m.StackInuse / 1024 // bytes of memory used by goroutine stacks
}
func main() {
fmt.Println("stack memory at start:", stackKB(), "KB")
block := make(chan struct{})
for i := 0; i < 10_000; i++ {
go func() { <-block }() // 10,000 goroutines, all waiting
}
// (no need to wait: each go statement allocates its stack right away)
fmt.Println("with 10,000 goroutines:", stackKB(), "KB")
}stack memory at start: 288 KB
with 10,000 goroutines: 20288 KB20,000 KB for 10,000 goroutines: exactly 2 KB each. But a fixed 2 KB would overflow almost immediately in real code. So how does a goroutine call deep functions?
Growing by copying
Every Go function begins with a tiny check (the compiler adds it): "is there room on this stack for my frame?" Almost always the answer is yes and it costs about one comparison. When the answer is no, the function calls into the Runtime, which grows the stack:
1func main() {2 x := 423 p := &x // into this stack4 deep(100_000) // needs more stack5 println(*p) // still 426}
main's frame holds x.You can see the move with a real program. p points at x, which lives on main's stack; a deep recursion forces the stack to grow:
//go:noinline
func deep(n int) int {
var pad [64]byte // make each frame a bit bigger
if n == 0 {
return int(pad[0])
}
return deep(n-1) + int(pad[n%64])
}
func main() {
x := 42
p := &x // p points at x, on this goroutine's stack
println("before: x is at", p) // built-in println: doesn't make x escape
deep(100_000) // needs much more stack → the stack is copied
println("after: x is at", p, "and *p =", *p)
}before: x is at 0xc000050730
after: x is at 0xc0020e7f30 and *p = 42The value of p itself changed — nobody in our code assigned to it. The runtime rewrote it while copying the stack. (println is a built-in that prints to stderr; we use it instead of fmt.Println because passing p to fmt would make x escape to the heap, and then nothing would move.)
A few more details worth knowing:
- Shrinking. During garbage collection, a goroutine using less than a quarter of its stack gets it copied into a half-size block. A goroutine that once recursed deeply doesn't hold that memory forever.
- Starting size can adapt. Since Go 1.19 the runtime tracks the average stack size and may start new goroutines with that instead of 2 KB. The current value is the
runtime/metricsmetric/gc/stack/starting-size:bytes; total stack memory is/memory/classes/heap/stacks:bytes(the same number asStackInuse). - History. Before Go 1.3, goroutine stacks were segmented: a full stack got a second chunk linked to it. A function call in a loop right at a chunk boundary would allocate and free a chunk on every iteration (the "hot split" problem). Copying fixed that, at the price of making stack addresses unstable — which is why Go code must never hide pointers from the runtime (e.g. by storing them as plain integers with
unsafe).
JavaScript: no stack to keep, so state goes on the heap
JS async functions take the opposite approach. They're stackless: when an async function hits await, its frame is popped like any other, and the variables it will need later are kept in a small heap object until it resumes. The Async/Await deep-dive (§08) shows that state machine step by step.
| Go goroutine (stackful) | JS async function (stackless) | |
|---|---|---|
| While waiting, its state lives in… | its own stack (≥ 2 KB) | a heap object holding just that function's variables |
| Can a deeply nested call wait? | yes — the whole stack is set aside | only directly inside an async function, via await |
| Cost per waiting task | a few KB | tens to hundreds of bytes |
| Stack trace after resuming | the real, complete stack | reconstructed by V8 ("async stack traces") |
Neither is "better": Go pays a few KB per goroutine so that any function can block; JS pays almost nothing per pending await but makes you mark every waiting function async.
Staff-level: when allocation matters (and when it doesn't)
Does it matter here? A back-of-the-envelope check
Usually not. A handler that makes a 5 ms database call and allocates 40 times (~20 ns each, so under 1 µs) spends about 0.02% of its time allocating. Rewriting it to allocate less buys nothing a user would notice.
It matters when the rate is high. Take a service doing 20,000 requests/s, where each request allocates 50 KB (decoding JSON, building responses, logging):
- 20,000 × 50 KB = ~1 GB of new heap objects per second.
- With Go's default settings (
GOGC=100), a GC cycle starts roughly each time the heap has grown by as much as the live data. With ~100 MB of live data, that's a cycle about every 100 MB allocated — about 10 GC cycles per second. - Each cycle uses CPU (by default, the Go GC aims to use about 25% of the CPU while it runs), and goroutines that allocate during a cycle are made to help with the GC's work (a Mark assist). That help is the extra latency you see at p99.
Play with the numbers:
Halve the bytes per request and you roughly halve the GC cycles. The mechanics — and the GOGC and GOMEMLIMIT knobs — are covered on the Garbage collection page. The point here: bytes allocated per request is a capacity metric, like CPU per request.
Find the allocators with an alloc profile
Never optimize allocations by intuition. pprof records where heap allocations happen. Get a profile from a benchmark, or from a running server:
# From a benchmark:
go test -bench . -benchmem -memprofile mem.out
go tool pprof -sample_index=alloc_space -top mem.out
# From a live server that imports _ "net/http/pprof":
go tool pprof -sample_index=alloc_space http://localhost:6060/debug/pprof/allocsType: alloc_space
Showing nodes accounting for 1990.54MB, 99.92% of 1992.04MB total
flat flat% sum% cum cum%
1574.54MB 79.04% 79.04% 1574.54MB 79.04% bench.newPointPtr
275MB 13.81% 92.85% 275MB 13.81% fmt.Sprintf
141MB 7.08% 99.92% 416.01MB 20.88% bench.keySprintf
0 0% 99.92% 416.01MB 20.88% bench.BenchmarkKeySprintf
0 0% 99.92% 1574.54MB 79.04% bench.BenchmarkPointPtrPick the right sample index:
| Question | Use | Shows |
|---|---|---|
| "Why is the GC so busy?" / "Why is p99 bad?" | alloc_space, alloc_objects | everything allocated since start — the churn |
| "Why is memory so high?" / "Is something leaking?" | inuse_space (the default for /debug/pprof/heap) | what's still alive right now |
For Node, the equivalents are the Allocation sampling profile (churn) and heap snapshots (what's alive) in Chrome DevTools, attached with node --inspect.
The fix toolbox, cheapest first
- Don't allocate at all. Return values instead of pointers for small structs; avoid
fmtand...anyon the hot path; don't convert[]byte↔stringback and forth. - Preallocate.
make([]T, 0, n),make(map[K]V, n),strings.Builder.Grow(n). - Let the caller own the buffer. APIs shaped like
AppendX(dst []byte, …) []byte(as instrconv.AppendInt) let one buffer be reused across calls, asappendKeydid in §08. - Store values, not pointers, in big slices and maps: fewer objects, and fewer pointers for the GC to trace.
- Pool expensive, short-lived objects with
sync.Pool— only when the profile proves it, and with its sharp edges in mind: see Object pools and sync.Pool. - Tune the GC (
GOGC,GOMEMLIMIT) to trade memory for fewer cycles — a config change, not a code change. See Garbage collection.
Measure the rate
runtime/metrics has /gc/heap/allocs:bytes; Node exposes GC events via perf_hooks.Profile, then fix the top 3
Re-check after upgrades
Pitfalls gallery
Returning pointers “for performance”
Many people coming from Java or C# write func NewX() *X and pass *X everywhere "to avoid copying". For small structs this usually makes things slower: the pointer can force a heap allocation (§06), and the GC has more pointers to chase. Copying a few words is nearly free.
Fix: default to values for small structs. Use pointers when the function must modify the original, the struct is large, or it must not be copied (it contains a sync.Mutex, a Lock).
“Pointer means heap, value means stack”
Neither half is reliable. A pointer that only goes down the stack doesn't escape; an inlined constructor's &p can stay on the stack (§06). A plain value converted to any, captured by an escaping closure, or bigger than the stack cap goes to the heap anyway.
Fix: ask the compiler (go build -gcflags=-m), then measure (-benchmem).
A pointer into a slice, then append
users := make([]string, 1, 1) // len 1, cap 1
users[0] = "ada"
first := &users[0] // pointer into the slice's array
users = append(users, "bob") // cap exceeded → NEW array, contents copied
users[0] = "ADA" // writes to the new array
fmt.Println(*first) // prints "ada" — first still points at the OLD arrayWhen append outgrows the capacity, it copies the elements into a new array (§09). Pointers you took earlier still point into the old one — no error, just silently stale data.
Fix: keep indexes instead of pointers into a slice that may grow, or take the pointers after the slice has stopped growing. (A slice of pointers, []*User, avoids this too — at the cost of one allocation per element.)
A tiny slice keeps a huge array alive
func firstLine(path string) ([]byte, error) {
data, err := os.ReadFile(path) // say, a 200 MB log file
if err != nil {
return nil, err
}
end := bytes.IndexByte(data, '\n')
if end < 0 {
end = len(data)
}
return data[:end], nil // ← 80 bytes that pin the whole 200 MB array
}A sub-slice shares the original backing array, and the GC can only free the whole array. As long as the 80-byte result is alive, so is every byte of the file.
Fix: copy what you keep: return bytes.Clone(data[:end]), nil (Go 1.20+). The JS version of this bug is a long-lived Closure that captures a huge object when it only needs one field from it.
Recursion over input you don't control
A recursive JSON walker, tree printer or comment-thread renderer works fine on test data — then a user sends something nested 100,000 levels deep and the process dies with a Stack overflow (a fatal error in Go; a RangeError in JS that may surface in a strange place).
Fix: put a depth limit on recursion over external data, or use a loop with an explicit to-do list (§03).
Shallow copies that share more than you think
const defaults = { retries: 3, headers: { "user-agent": "atlas" } };
const opts = { ...defaults }; // copies the top level only
opts.headers["x-debug"] = "1"; // ← also changes defaults.headers!Spreading { ...obj } (or a Go struct copy containing a slice or map) copies the references to the nested data, not the data.
Fix: copy the nested parts you'll modify ({ ...defaults, headers: { ...defaults.headers } }), or use structuredClone. In Go, clone the slice/map (slices.Clone, maps.Clone).
Optimizing allocations without a profile
Replacing readable code with buffers and pools in code that runs ten times a second makes it harder to maintain and gains nothing. The opposite mistake is just as common: benchmarking code whose result is never used, so the compiler deletes the work and the numbers look magically fast.
Fix: start from an alloc profile of the real workload (§11). In Go 1.24+ benchmarks, use for b.Loop(), which keeps the compiler from optimizing the benchmarked call away.
Check yourself
Commit to an answer before you click. Each explanation points back to the section that covers it.
type Config struct{ Port int; Host string }
func load() *Config {
c := Config{Port: 8080, Host: "localhost"} // A
return &c // B
}const a = { hits: 1 };
const b = a;
b.hits++;
let x = 1;
let y = x;
y++;
console.log(a.hits, x);function walk(node) {
return walk(node.next ?? node); // a cycle: never ends
}
walk({});Want more practice on the Go side? The "Will it escape?" widget in §06 has nine more cases with the compiler's real verdicts.
Cheat sheet
go build -gcflags=-m shows every decision.-benchmem, AllocsPerRun, pprof alloc_space; Node --trace-gc, process.memoryUsage(), DevTools allocation sampling.| I want to… | Go | JavaScript (Node) |
|---|---|---|
| See where values live | go build -gcflags=-m (-m=2 for reasons, -l no inlining) | — (V8 decides at run time) |
| Count allocations per call | go test -bench . -benchmem | node --trace-gc (count Scavenges) |
| Assert "no allocations" in a test | testing.AllocsPerRun | — |
| Find who allocates most | go tool pprof -sample_index=alloc_space | DevTools → Memory → Allocation sampling |
| Find what's still alive | pprof inuse_space (/debug/pprof/heap) | DevTools heap snapshot |
| Check heap size now | runtime.ReadMemStats, runtime/metrics | process.memoryUsage() |
| Avoid append regrowth | make([]T, 0, n) | push is fine (V8 grows arrays for you); typed arrays for big numeric data |
| Build strings in a loop | strings.Builder | parts.push(…) then parts.join("") |
| Reuse expensive objects | sync.Pool (after profiling) | keep and reuse buffers / typed arrays |
| Deep or untrusted nesting | loop + explicit stack (slice) | loop + explicit stack (array) |