Skip to main content

Command Palette

Search for a command to run...

Two Places to Put Things

Stack and Heap

Updated
8 min read
Two Places to Put Things
D

Engineering @ Bolt

When a function is called, the CPU needs somewhere to put a few specific things: the arguments passed in, the local variables declared inside, and the address to return to when the function finishes. It needs this storage to materialize instantly when the call begins and vanish completely when the call ends. Not eventually. Not after a GC (garbage collection) cycle. Now, and then gone.

The stack is that storage. It is a region of memory that grows and shrinks in lock-step with the call depth, each new function call pushing a frame on top, each return popping one off. The CPU maintains a hardware register called the stack pointer, which always points to the current top. Allocation is a single arithmetic instruction, just move the pointer up by however many bytes this frame needs. Deallocation is the same, move it back. The compiler knows at compile time exactly how large each frame will be, because local variables have fixed types and fixed sizes. There is no searching, no bookkeeping, no garbage collector watching from a distance.

The x86 architecture has instructions dedicated to this: PUSH, POP, CALL, RET. The CPU has been doing this since the 1970s. It predates your runtime. It predates your programming language. It predates, in all likelihood, your opinion about which programming language is correct, and it does not care about that opinion at all.

This is the stack's enormous strength. It is supremely fast, supremely easy. It is also the only thing the stack can do.


Here is the constraint. Not all data fits that model.

Consider a string that a user types at runtime. You do not know its length at compile time. You wrote the program expecting smaller names like, "Albus", "Eximius", or even a "Tom", but it was a string, and the user wrote his full name, "Aliaune Damala Bouga Time Bongo Puru Nacka Lu Lu Lu Badara Akon Thiam". Now consider an object that one function creates and an entirely different function, called much later, is responsible for destroying. Consider a tree that grows and shrinks as data flows in, whose size at any given moment is unknowable until the program is actually running. None of these fit in a stack frame. The compiler cannot reserve a slot for something whose size it does not know. A function cannot own something that must outlive it. If you try to force it, the program crashes, and the crash will have your name on it.

The heap is the answer to this problem.

The heap is a region of memory managed by a software allocator. When you need memory at runtime, you ask the allocator for it. The allocator searches its internal structures, finds a suitable free block, marks it as used, updates its bookkeeping, and returns a pointer. That memory persists until something explicitly releases it, either you, or a garbage collector, or the process terminating and the OS taking everything back regardless. Its lifetime has nothing to do with any particular function call. It lives precisely as long as the program decides it should, which is sometimes longer than the program intended, and that is where leaks come from.

The trade-off is that all of that searching and bookkeeping has a cost. Allocating on the heap requires actual work from the allocator, because it has to find free space, handling fragmentation, updating structures that other threads may also be trying to update, all that for Akon. That is orders of magnitude slower than moving a stack pointer. In a hot path processing tens of thousands of requests per second, that difference is measurable.


Languages make different choices about how much of this they expose to you, and those choices carry real consequences.

In C and C++, you manage both explicitly. Stack allocation is int x = 5. Heap allocation is malloc and free. You are entirely responsible for calling free at the correct time. Forget it and you leak memory. Call it twice and you corrupt the heap. The language trusts you completely and this trust is, historically speaking, somewhat misplaced, which is one reason memory-safe languages exist now.

In Java, the JVM allocates almost everything on the heap, and the garbage collector handles deallocation. You do not usually think about stack versus heap, you think about object lifetimes and GC tuning, which is a different and more bureaucratic kind of suffering. The JVM does perform escape analysis to keep short-lived objects on the stack when it can, but that is invisible to you unless you are reading JIT compiler output, which most people have never done and will not start doing today.

In Go, the compiler performs escape analysis to decide where each variable should live. If a variable is returned by reference from a function, it must escape to the heap, because the stack frame will be gone by the time the caller uses it. If it never leaves the function, it stays on the stack. You can see the compiler's decisions by running go build -gcflags='-m'. It will tell you, with a candor that can feel mildly accusatory, exactly which variables escaped to the heap and why. Every escape is a heap allocation. Every heap allocation is something the GC must eventually trace, mark, and reclaim.

This is not theoretical, at high request rates, reducing unnecessary heap allocations is one of the highest-leverage performance optimizations available, and it costs nothing except the attention to look.

In Rust, the ownership system makes the distinction explicit and compiler-enforced. Stack memory is owned by a scope and freed when that scope exits, the compiler inserts the deallocation automatically. Heap memory lives inside types like Box, Vec, or String and is freed when those types are dropped. There is no GC, because the compiler has already proven, at compile time, exactly where each piece of memory should be freed. Whether this sounds elegant or exhausting depends on your prior experiences with memory bugs, and also on what day of the week it is.


In production, the stack and heap show up through specific failure modes.

Stack overflow happens when the call stack grows too deep. Unbounded recursion is the classic cause, each call adding a frame until there is no stack space left. In Go, goroutines start with a small initial stack that grows dynamically, but each growth event involves copying the entire stack to a new allocation. In languages with fixed-size thread stacks, the overflow is more abrupt, the process crashes.

Heap fragmentation happens when the allocator hands out and reclaims many small allocations over a long runtime, leaving the heap pocked with small free holes that cannot satisfy large requests. Long-running services accumulate fragmentation gradually.

GC pressure is the most familiar failure mode in modern services, and it is entirely a heap concern. The GC does not care about stack-allocated values; those vanish automatically when their function returns. It cares only about the heap, where lifetimes are ambiguous and ownership is diffused. When a Go service shows elevated GC mark worker CPU or rising pause times under load, the first question is not "how do I tune GOGC?" The first question is "why are we generating this much garbage?" The answer lives in the allocation profile. The allocation profile almost always has an opinion about your hot path. Try it the next time you build a go program.

go build -gcflags='-m'

The mental model worth holding is this, the stack is for values whose size is known at compile time and whose lifetime is bounded by the function that created them. The heap is for everything else. Every heap allocation is a small claim that this object's lifetime cannot be determined statically, and every time you can refute that claim by keeping something on the stack, you have done the GC's work for it, for free, on every single request.

The stack and the heap are not just memory regions. They are two different answers to a question every running program asks constantly, silently, at machine speed, where should this data live, and for how long?

Now when you look at a struct returned by value, you see a stack allocation. You look at a pointer return and see a potential escape. You run go build -gcflags='-m' for the first time and feel mildly judged by your own compiler.

That feeling is the beginning of understanding it.