— notes

How Goroutine Stacks Grow and Shrink

2 min read#go

If you ask any mid-senior Go dev what makes goroutines lightweight you’ll get the reply:

They start with 2 KB of stack instead of 1 MB like OS threads.

They’re not wrong. But they’re not thinking deep enough.

Go’s stack model isn’t just a small preallocated buffer; it’s a live, evolving region of memory that resizes in realtime, grows when needed, and shrinks.

Go’s runtime gives each new goroutine 2 KB of stack. But Go doesn’t panic when you blow past it – it grows the stack dynamically by allocating a new region (typically doubling the size) and copying the old stack frames over.

Stack Growth Lifecycle

graph LR
    A["New Goroutine\n2 KB stack"] --> B["Function\nCall"]
    B --> C{"Stack\nCheck"}
    C -->|Enough| D["Continue\nExecution"]
    C -->|Overflow| E["Allocate\n2x Stack"]
    E --> F["Copy Old\nFrames"]
    F --> G["Update\nPointers"]
    G --> D

Each goroutine has an upper stack limit of about 1 GB. Hit it and the program panics:

runtime: goroutine stack exceeds 1000000000-byte limit
fatal error: stack overflow

Stack Size Progression

graph TD
    S1["2 KB\n(initial)"] --> S2["4 KB"]
    S2 --> S3["8 KB"]
    S3 --> S4["16 KB"]
    S4 --> S5["32 KB"]
    S5 --> S6["..."]
    S6 --> S7["~1 GB\n(ceiling)"]
    S7 -.->|"GC shrink\n(if idle + mostly unused)"| S5
    S5 -.->|"GC shrink"| S3

Contiguous Stack Copy

When a stack outgrows its current allocation, Go allocates a new contiguous block at double the size, copies all frames, and adjusts every internal pointer.

graph LR
    subgraph "Old Stack (4 KB)"
        O1["Frame 1"]
        O2["Frame 2"]
        O3["Pointer A\n→ Frame 1"]
    end
    subgraph "New Stack (8 KB)"
        N1["Frame 1\n(copied)"]
        N2["Frame 2\n(copied)"]
        N3["Pointer A'\n→ Frame 1\n(adjusted)"]
        N4["Free Space"]
    end
    O1 -->|copy| N1
    O2 -->|copy| N2
    O3 -->|"adjust + copy"| N3

Stack growth can trigger GC pressure even without heap allocations. A runaway stack holds pointers that get scanned by GC. This is how your 5ms p99 turns into 100ms.

There’s no tuning knob for stack size. No config. No CLI flag. The only way to manage it is through code discipline: avoid recursive goroutines unless they terminate quickly, don’t hold large structs deep in call graphs, and never assume goroutines are free.