Skip to content
Software Atlas

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.

⏱ ~45 min read14 sections10 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

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.

In one sentenceThe stack holds a function's local variables and throws them away, for free, the moment the function returns. The heap holds anything that must outlive the call — which costs more to create, and someone has to clean it up later.
1

Stack: automatic

Each function call gets a frame (one sticky note) with its local variables. Return → the frame is gone. Cost: almost nothing.
2

Heap: outlives the call

Objects that must survive after their function returns, or whose size isn't known in advance, go to the heap. Cost: the allocator's work now, plus the garbage collector's work later.
3

Who decides?

Go: the compiler decides, using escape analysis (§06), and can tell you its decision. JavaScript: objects, arrays and functions go on the heap; the engine optimizes behind your back (§07).

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:

the same work, two homes
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 heap
go test -bench . -benchmem (this machine, approximate)
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/op

About 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.

Foundations§02

The call stack: a pile of frames

In one sentenceEvery function call pushes a frame on top of the Call stack; every return pops it off. Last in, first out — like a pile of plates.

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

One frame per call that hasn't returned yet
main() — bottom: the first call
handleRequest(req)
getUser(id) ← top: running now
arguments: id = 42locals: row, errreturn address: handleRequest, line 4
▲ stack pointer marks the top

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:

The call stack, plate by plate
Step with ← →. Watch frames push on the way in and pop on the way out.
Code
1function fact(n) {
2 if (n <= 1) return 1; // base case: stop here
3 const rest = fact(n - 1); // call ourselves with a smaller n
4 return n * rest;
5}
6
7console.log(fact(3));
Call stacktop = running now
script (main) · the first frame
Stack pointer · space used
stack pointer → frame 1 · ~1% used
Console
(no output yet)
The program starts. The script itself gets the first frame — the bottom plate of the pile. Line 7 is about to call fact(3).
1 / 9

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 frame

A 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.)

Foundations§03

Stack overflow: when the pile hits the ceiling

In one sentenceA stack has a maximum size. A function that keeps calling itself without stopping keeps adding frames until the pile hits the ceiling — a Stack overflow.

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:

The call stack, plate by plate
Step with ← →. Watch frames push on the way in and pop on the way out.
Code
1function fact(n) {
2 // ⚠ base case forgotten!
3 const rest = fact(n - 1); // calls itself forever
4 return n * rest;
5}
6
7console.log(fact(3));
Call stacktop = running now
script (main) · the first frame
Stack pointer · space used
stack pointer → frame 1 · ~1% used
Console
(no output yet)
Same program, but someone deleted the base case. Line 7 calls fact(3)…
1 / 7

The real error messages

overflow.js
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: 10463

Both are real runs (Node 22, Go 1.24 on Linux). Two differences are worth noticing:

  • JavaScript throws a normal error (RangeError), so try/catch can 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 stackTypical sizeCan 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 Goroutinestarts 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

✕ Recursion with no limit on depth
// 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;
}
✓ An explicit stack on the heap
// 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;
}
Foundations§04

The heap: a warehouse with a manager

In one sentenceThe Heap is one big pool of memory where values can live as long as they're needed — not just until their function returns. The price: someone has to find room for each value, and someone has to clean it up.

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:

things that may allocate on the heap
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:

The heap manager at work
16 slots of heap memory. Each object needs a run of free slots next to each other.
0/16 slots used
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
The heap is empty. Allocate a few objects, free some, then try a big one.
Compare the stack: it never searches. A new frame always goes on top, so “allocating” is one step — move the stack pointer — and there are never holes.

Why the heap costs more than the stack

Four separate costs, from most to least obvious:

  1. 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".
  2. 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.
  3. 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.
  4. 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.
The same request, two places (illustrative)
Stack
  1. Move the stack pointer.
  2. (on return) Move it back. Done.
Heap
  1. Pick the right size bucket; find a free slot.
  2. Mark it taken; clear old bytes to zero.
  3. Hand back a pointer; store it somewhere.
  4. (later) GC finds everything still reachable from your variables…
  5. (later) …and frees the slot if nobody points to it.
Practitioner§05

Values, pointers and references

In one sentenceAssigning a value copies the data. Assigning a pointer (or a JS object reference) copies only the address — so two names end up sharing one thing, usually on the heap.

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:

Stack frames, heap objects, and the arrows between them
Step with ← →. Watch what survives when a function returns.
Code
1type Point struct{ X, Y int }
2
3func main() {
4 a := Point{1, 2} // a struct value
5 b := a // b is a full COPY
6 b.X = 99 // a.X is still 1
7 p := newPoint() // p holds an address
8 p.X = 7 // changes the heap object
9}
10
11//go:noinline
12func newPoint() *Point {
13 q := Point{3, 4}
14 return &q // q's address leaves → heap
15}
Stack
main()
a{X:1 Y:2}
Heap
nothing on the heap
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.
1 / 7

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:

value vs pointer parameter
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

✕ Pointers by habit
// 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}
}
✓ Values by default, pointers for a reason
// 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…

The function must change the caller's data; the struct is large (copying hundreds of bytes on every call adds up); or it must have one identity — e.g. it contains a sync.Mutex (a Lock), which must never be copied.
✗

A pointer costs you…

A possible heap allocation if it escapes; more GC work, since the collector must follow every pointer; a cache miss when the target is far away; and sharing bugs — someone else can change your data.
≈

Copying is cheap

Copying a 24-byte struct is a couple of CPU instructions. Copying is not what makes Go code slow; allocating is. On the benchmark in §01, the value version was ~18× faster.

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 = n gives m its own copy; nothing you do to m can change n.
  • Everything else — objects, arrays, functions, Maps, class instances — lives on the heap, and a variable holds a Reference to it. const b = a copies the reference, so a and b are two names for one object.
copy the reference vs copy the 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 copy
Practitioner§06

Go's escape analysis: who decides

In one sentenceIn Go you never write "put this on the heap". The Compiler decides for each value, using Escape analysis: if it can prove the value isn't used after its function returns, it stays on the stack. Otherwise it escapes to the heap.

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:

main.go
package main

type Point struct{ X, Y int }

func newPoint() *Point {
	p := Point{1, 2}
	return &p
}

func main() {
	q := newPoint()
	println(q.X)
}
go build -gcflags=-m .
./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: p

Wait — "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.

Same code, inlined or not
Flip the toggle: where does p end up?
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)
}
Stack
main()
q→ heap
newPoint()popped on return
p→ heap
Heap
Point (p)
{X:1 Y:2}
AllocsPerRun: 1 per call
$ go build -gcflags=-m .
./main.go:11:6: can inline main
./main.go:7:2: moved to heap: p
A real call happens. newPoint 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:

go build -gcflags='-m=2 -l' .
./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: p

Read 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.)

Will it escape?
Guess first. The verdicts are real output of go build -gcflags='-m -l' on Go 1.24.
score 0/0 · 0/9 tried
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() }
Where does p live?

The common reasons a value escapes

The value…ExampleWhy
has its address returnedreturn &pthe caller outlives the frame
is stored somewhere longer-livedglobal = &p, cache[k] = &p, a field of a heap objectthat place outlives the frame
is captured by a closure that outlives the callreturn func() { count++ }the closure runs after the frame is gone
is sent on a channel (as a pointer)ch <- &panother goroutine, another stack
is converted to an interface the compiler can't followfmt.Println(n), var s Shape = rthe interface holds a pointer to a copy
has a size only known at run timemake([]int, n)a frame's size is fixed at compile time
is too bigvar buf [256 << 10]bytestack 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 escape for case 6 of the quiz, where Go 1.24 said it escapes (we checked with both toolchains). Re-check old assumptions after an upgrade.
Practitioner§07

JavaScript: (almost) everything object-like lives on the heap

In one sentenceJavaScript has no & 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

One V8 frame and the heap it points into (simplified)
frame: handle(req)
count = 3 (Smi, stored inline)
req → ●
items → ●
price → ● (9.99 is boxed)
req: { url, headers, … }
items: [ ●, ●, ● ] → 3 more objects
HeapNumber 9.99
closure context { count } — only if a closure captures it
  • 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 count to 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:

churn.js
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 itMinor 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
one line of --trace-gc output (from the --no-turbofan run)
[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).
Practitioner§08

Measuring allocation cost

In one sentenceDon't guess where allocations are — count them. Go's -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:

key.go
// 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:

key_test.go
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:

point_test.go
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)
    }
}
go test -bench . -benchmem (Go 1.24, this machine)
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/op

How 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.

ns per call, measured with go test -bench -benchmem (this machine, approximate)
newPointVal — returns a Point
1 ns · 0 allocs/op
newPointPtr — returns a *Point
18.3 ns · 1 alloc/op · 24 B
keySprintf — fmt.Sprintf
66.5 ns · 2 allocs/op · 24 B
appendKey — reused buffer
10.3 ns · 0 allocs/op

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:

mem.js — run with: node --expose-gc mem.js
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()");
output (Node 22, this machine)
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 MB
  • heapUsed is 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, then x and y). The array adds an 8-byte slot per object, plus spare room left over from growing.
  • rss is 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.

Practitioner§09

Where hidden allocations come from

In one sentenceMost allocations don't come from new or &. They hide inside ordinary-looking code: an any parameter, a + between strings, a growing slice, a closure.

All numbers below are from go test -bench -benchmem on this page's machine (Go 1.24). Treat the times as approximate; the allocation counts are exact and stay the same on any 64-bit machine running the same Go version.

1. append growth

A Go Slice has a length (how many elements are in use) and a capacity (how many fit in the array behind it). When append needs more room than the capacity, it can't extend the array in place. It allocates a new, bigger array, copies everything over, and leaves the old one as garbage. Click until you see it:

append, one element at a time
A slice []int grows by allocating a bigger array and copying. Capacities are real Go 1.24 values.
len = 0cap = 0heap allocations = 0elements copied = 0
∅
A nil slice has no array at all (cap 0). Press append 1.

The growth rule (Go 1.18+): double the capacity while it's small, then grow by about 1.25× once it passes 256 elements, then round up to the allocator's next Size class — which is why you see odd numbers like 848. That's the runtime's business, not something to depend on. What matters: appending 1,000 items one by one to a nil slice made 12 allocations and allocated 50 KB; preallocating made 1 allocation of 16 KB.

✕ Grow as you go
var out []Item // nil: capacity 0
for _, r := range rows {
    out = append(out, toItem(r))
}
// 1,000 rows: 12 allocs, ~50 KB
✓ Preallocate when you know the size
out := make([]Item, 0, len(rows))
for _, r := range rows {
    out = append(out, toItem(r))
}
// 1,000 rows: 1 alloc, ~16 KB

2. Slices of pointers

✕ One heap object per item
func pointers(n int) []*Item {
    out := make([]*Item, 0, n)
    for i := 0; i < n; i++ {
        out = append(out, &Item{ID: i, Qty: 1})
    }
    return out
}
// n = 1,000: 1,001 allocs/op · ~18 µs
✓ Items stored inside the slice
func values(n int) []Item {
    out := make([]Item, 0, n)
    for i := 0; i < n; i++ {
        out = append(out, Item{ID: i, Qty: 1})
    }
    return out
}
// n = 1,000: 1 alloc/op · ~6 µs

A []*Item holds addresses; each &Item{…} is its own heap object. A []Item stores the items inside the slice's one array. That's 1,000 fewer allocations, 1,000 fewer pointers for the GC to follow, and items sitting next to each other in memory, which the CPU cache loves. Use []*T when items must be shared or are large; otherwise []T is the default.

3. Interfaces, fmt and logging

Any value passed as any (or any other Interface) needs a pointer to a copy of it — usually a heap copy, unless the compiler can see through the call (§06). Here's what an interface value looks like inside:

What's inside an interface value
Why converting to an interface (any, error, Shape, …) often allocates.
var s Shape = Rect{W: 3, H: 4}
interface value (stack) · 2 words
typeRect (+ its Area method)data●
heap · 1 allocation
→ heap copy of Rect{3 4}
An interface value is two words: one says which type is inside (and where its methods are), the other is a pointer to the data. The Rect is copied to a place that pointer can reach — the heap, when the compiler can't prove the interface stays local.

fmt.Sprintf, fmt.Println, log.Printf, fmt.Errorf and structured loggers with ...any arguments all work this way.

log.Printf("user %d bought %d items", userID, n) // each int boxed into an any (unless it is 0–255), plus the output

On a request path that's fine. Inside a loop over a million rows, it's a million+ allocations.

4. Building strings

Strings can't be changed, so a + b always creates a new string. In a loop, that's one allocation per step, each copying everything so far:

✕ += in a loop
s := ""
for _, w := range words { // 100 words
    s += w + " "
}
// 100 allocs/op · ~14 µs
✓ strings.Builder
var sb strings.Builder
for _, w := range words {
    sb.WriteString(w)
    sb.WriteByte(' ')
}
s := sb.String()
// 8 allocs/op · ~1.1 µs

Conversions copy too: []byte(s) and string(b) usually allocate (the compiler skips the copy in a few special cases, like a map lookup m[string(b)]).

5. Closures and goroutines

A Closure that outlives its function needs a heap object to hold its captured variables (the "closure returned" case in §06). Every go func() { … }() also allocates: the goroutine gets its own stack (§10), plus a small object for the closure if it captures variables.

And in JavaScript

The same ideas, different syntax. Each of these creates new heap objects on every call:

const total = items.map((i) => i.price).filter((p) => p > 0).reduce((a, b) => a + b, 0);
// ↑ map and filter each build a whole new array, just to be thrown away

const next = { ...state, count: state.count + 1 };  // a new object, all fields copied
const merged = [...a, ...b];                         // a new array
const label = `${user.first} ${user.last}`;          // a new string
const onClick = () => save(id);                      // a new function object (closure)

All perfectly fine in normal code — readable beats fast until a profile says otherwise. In a hot loop, a plain for loop with a running total does the first line with zero intermediate arrays.

Staff-level§10

Goroutine stacks: small, growable, movable

In one sentenceA Goroutine stack starts tiny. When it fills up, the runtime copies the whole stack into a block twice as big and fixes every pointer that pointed into the old one. That's how Go can afford a million goroutines.

§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:

stacks.go
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")
}
output (Go 1.24, linux/amd64)
stack memory at start: 288 KB
with 10,000 goroutines: 20288 KB

20,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:

How a goroutine stack grows
Go stacks are not fixed-size. They move.
Code
1func main() {
2 x := 42
3 p := &x // into this stack
4 deep(100_000) // needs more stack
5 println(*p) // still 42
6}
Old stack · 2 KB
main · x = 42 @ …0730
New stack · 4 KB
not allocated yet
Pointers into the stack
p = —
A new goroutine starts with a tiny stack — 2 KB by default (since Go 1.19 the runtime may start it at the average size goroutines have needed so far). main's frame holds x.
1 / 7

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:

grow.go
//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)
}
go run grow.go (Go 1.24)
before: x is at 0xc000050730
after:  x is at 0xc0020e7f30 and *p = 42

The 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/metrics metric /gc/stack/starting-size:bytes; total stack memory is /memory/classes/heap/stacks:bytes (the same number as StackInuse).
  • 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 asideonly directly inside an async function, via await
Cost per waiting taska few KBtens to hundreds of bytes
Stack trace after resumingthe real, complete stackreconstructed 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§11

Staff-level: when allocation matters (and when it doesn't)

In one sentenceA single allocation costs nanoseconds. What hurts in production is the allocation rate — megabytes per second — because that's what decides how often the garbage collector has to run.

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:

Allocation rate decides how often the GC runs
A simplified Go model: 20,000 requests/s, 100 MB of live data, GOGC=100 (a cycle starts when the heap reaches 200 MB).
GC starts at 200 MBlive data 100 MB0 s1 s
allocation rate ≈ 977 MB/sGC cycles ≈ 9.8 per second
Each drop is a GC cycle freeing the garbage. Double the KB per request and the cycles per second double too — and so does the GC's CPU use and the time goroutines spend helping it. (Illustrative: the real pacer is smarter, and freeing isn't instant.)

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/allocs
go tool pprof -sample_index=alloc_space -top mem.out (the §08 benchmarks)
Type: 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.BenchmarkPointPtr

Pick the right sample index:

QuestionUseShows
"Why is the GC so busy?" / "Why is p99 bad?"alloc_space, alloc_objectseverything 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

  1. Don't allocate at all. Return values instead of pointers for small structs; avoid fmt and ...any on the hot path; don't convert []byte ↔ string back and forth.
  2. Preallocate. make([]T, 0, n), make(map[K]V, n), strings.Builder.Grow(n).
  3. Let the caller own the buffer. APIs shaped like AppendX(dst []byte, …) []byte (as in strconv.AppendInt) let one buffer be reused across calls, as appendKey did in §08.
  4. Store values, not pointers, in big slices and maps: fewer objects, and fewer pointers for the GC to trace.
  5. 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.
  6. Tune the GC (GOGC, GOMEMLIMIT) to trade memory for fewer cycles — a config change, not a code change. See Garbage collection.
1

Measure the rate

Track bytes allocated per request (or per second) like you track CPU. In Go, runtime/metrics has /gc/heap/allocs:bytes; Node exposes GC events via perf_hooks.
2

Profile, then fix the top 3

Allocation profiles are usually lopsided: a few call sites cause most of the bytes. Fix those and stop.
3

Re-check after upgrades

Each Go and V8 release changes inlining and escape analysis. An optimization from two years ago may be unnecessary — or broken — today.
Practitioner§12

Pitfalls gallery

#1

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).

#2

“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).

#3

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 array

When 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.)

#4

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.

#5

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).

#6

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).

#7

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.

Practitioner§13

Check yourself

Commit to an answer before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 6
Which line moves a value to the heap?
type Config struct{ Port int; Host string }

func load() *Config {
    c := Config{Port: 8080, Host: "localhost"} // A
    return &c                                    // B
}
QUESTION 2 / 6
Predict the output
const a = { hits: 1 };
const b = a;
b.hits++;
let x = 1;
let y = x;
y++;
console.log(a.hits, x);
QUESTION 3 / 6
What happens here?
function walk(node) {
  return walk(node.next ?? node); // a cycle: never ends
}
walk({});
QUESTION 4 / 6
go test -benchmem says 3 allocs/op. What does that tell you?
QUESTION 5 / 6
A goroutine's stack is full. What does Go 1.24 do?
QUESTION 6 / 6
Your p99 latency spikes and the profile shows lots of GC work. First move?
Answered 0 / 6

Want more practice on the Go side? The "Will it escape?" widget in §06 has nine more cases with the compiler's real verdicts.

Foundations§14

Cheat sheet

📚
Stack: per call, automatic, nearly freeA frame holds a call's arguments, locals and return address. Return = pop = gone. Limited size: ~1 MB in V8, 8 MB per OS thread, 2 KB→1 GB growable per goroutine.
🏭
Heap: outlives the callAnything still reachable after its function returns. Costs the allocator's work now and the GC's work later.
🔍
Go: the compiler decidesEscape analysis keeps a value on the stack only if it can prove nothing uses it after return. go build -gcflags=-m shows every decision.
🚪
What escapesReturned/stored pointers, escaping closures, channel sends of pointers, interface conversions it can't see through, run-time sizes, very large values.
📦
Values vs referencesGo structs copy; pointers, slices and maps share. JS primitives copy; objects, arrays and functions are shared references on the heap.
⚡
V8 helps behind your backFast bump allocation for new objects; TurboFan can remove temporary objects that never leave an optimized function.
📈
Allocation rate drives GCBytes allocated per second decide how often the GC runs. That's what shows up in CPU and p99, not any single allocation.
🧪
Measure, then fix the top few-benchmem, AllocsPerRun, pprof alloc_space; Node --trace-gc, process.memoryUsage(), DevTools allocation sampling.
I want to…GoJavaScript (Node)
See where values livego build -gcflags=-m (-m=2 for reasons, -l no inlining)— (V8 decides at run time)
Count allocations per callgo test -bench . -benchmemnode --trace-gc (count Scavenges)
Assert "no allocations" in a testtesting.AllocsPerRun—
Find who allocates mostgo tool pprof -sample_index=alloc_spaceDevTools → Memory → Allocation sampling
Find what's still alivepprof inuse_space (/debug/pprof/heap)DevTools heap snapshot
Check heap size nowruntime.ReadMemStats, runtime/metricsprocess.memoryUsage()
Avoid append regrowthmake([]T, 0, n)push is fine (V8 grows arrays for you); typed arrays for big numeric data
Build strings in a loopstrings.Builderparts.push(…) then parts.join("")
Reuse expensive objectssync.Pool (after profiling)keep and reuse buffers / typed arrays
Deep or untrusted nestingloop + explicit stack (slice)loop + explicit stack (array)
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in OS & RuntimeGarbage Collection
Read →