Memory: stack vs heap

Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: Computers have RAM. Programs use it in two distinct shapes — a stack (fast, tiny, for short-lived data) and a heap (bigger, slower, for things that need to outlive the function that made them). In JavaScript this is mostly hidden; understanding it explains the rest.


In plain English

When a program runs, it needs MEMORY (RAM) for its variables, objects, strings, function calls — everything. The operating system gives the program a chunk of memory; the program organizes it into two main regions:

  • The stack — small, very fast, organized like a stack of plates. New data is pushed on top; data is popped off the top. Used for function calls, local primitive variables, return addresses.
  • The heap — much bigger, slower, more flexible. Used for objects, arrays, strings — anything whose lifetime isn’t tied to a single function call.

Why two? Because the two have completely different USAGE PATTERNS:

  • Stack data — known size, predictable lifetime. “When this function returns, throw it all away.” Allocation = bump a pointer (one CPU instruction). Free = bump the pointer back. Microseconds.
  • Heap data — variable size, unpredictable lifetime. “Allocate this object; it might outlive several function calls.” Allocation requires finding free space. Free requires tracking when nothing references it anymore.

In JavaScript, you DON’T explicitly choose stack or heap — the runtime decides. Roughly:

  • Primitives (numbers, strings, booleans, null, undefined, symbols, bigints) → usually stack
  • Objects (object literals, arrays, functions, Maps, Sets, Promises) → always heap

You also don’t manually FREE memory (unlike C/C++). JavaScript has a garbage collector that automatically reclaims heap memory you stop referencing.

For Bible Quest-style work: this is largely INVISIBLE. The runtime handles it. But knowing the model explains:

  • Why “primitives are passed by value, objects are passed by reference”
  • Why “stack overflow” happens (excessive recursion fills the stack)
  • Why “memory leak” is a thing (the heap retains things it shouldn’t)
  • What “garbage collection” is doing when your app briefly stutters

For lower-level perspectives: this entry is the conceptual one. For specific JavaScript memory behavior, see JavaScript.


Why it matters

Three concrete reasons even a non-coder benefits:

  1. It explains JavaScript quirks. “Why does modifying this object inside a function ALSO change it outside?” → because objects live on the heap; the variable holds a reference, not the data. Knowing this prevents whole categories of bug.

  2. Memory leaks are real. Modern webapps can leak memory through closures, event listeners, references — slowly degrading performance until refresh. Knowing the mechanism makes them diagnosable.

  3. Stack overflows have a cause. Recursion + the stack model explain “Maximum call stack size exceeded” errors and why iteration is the fix.

The trade-off: in JavaScript, you’ll never WRITE code that explicitly allocates on the stack vs heap. The model is for UNDERSTANDING, not for control.


The stack — fast and ordered

Picture the stack as a literal stack of plates. Each function call PUSHES a frame on top; when the function returns, its frame POPS off.

Function A() {
  let x = 5;
  B();
}
Function B() {
  let y = 10;
  C();
}
Function C() {
  let z = 15;
}

When A() runs and calls B() and B() calls C():

Stack at peak:
+--------------+
| C's frame    |  z = 15
+--------------+
| B's frame    |  y = 10
+--------------+
| A's frame    |  x = 5
+--------------+

Each frame holds:

  • Local variables (primitives directly; objects as references to heap)
  • The return address (where to resume in the caller)
  • Function arguments

When C() returns, its frame is popped. When B() returns, its frame is popped. When A() returns, its frame is popped. The stack shrinks back to empty.

Properties:

  • LIFO (last in, first out) order
  • Allocation is just “move the stack pointer down”
  • Free is just “move the stack pointer up”
  • Extremely fast
  • Bounded in size (typically 1-8 MB total)

This bounded size is the source of stack overflow: too many function calls in flight (deep recursion) fill the stack to the limit.


The heap — flexible and persistent

The heap is a big pile of memory available to the program. Allocation can happen at any time, in any size, and stays valid until explicitly freed (or, in JavaScript, until the garbage collector decides it’s unreachable).

const arr = [1, 2, 3];          // heap-allocated array; `arr` is a reference
const obj = { name: "George" }; // heap-allocated object; `obj` is a reference
const fn = () => 42;             // heap-allocated function

The local variables arr, obj, fn live on the stack — but they hold POINTERS (references) to the actual data on the heap.

Properties:

  • Random allocation order
  • Can hold arbitrarily large objects (up to total memory limit)
  • Slower to allocate / free
  • Survives across function calls (until released)
  • In JavaScript: managed by the garbage collector

When the function that created an object returns, the LOCAL VARIABLE pointing to it pops off the stack — but the OBJECT itself stays on the heap as long as something else references it. If nothing references it, the garbage collector eventually reclaims it.


A concrete example showing the difference

function makeUser(name) {
  const user = { name };       // object on heap; `user` (the variable) on stack
  return user;                  // returns a reference
}
 
const g = makeUser("George");
const a = makeUser("Alice");
// `g` and `a` are stack variables holding REFERENCES to two
// different heap-allocated objects.
 
// Even though makeUser has returned, the objects persist on the heap
// because `g` and `a` still reference them.
 
g.name;       // "George"
a.name;       // "Alice"
 
const sameAsG = g;          // sameAsG now references the SAME object as g
sameAsG.name = "Geo";
 
g.name;       // "Geo" — same object! Mutation visible through both refs.

Key insight: g, a, sameAsG are SEPARATE variables on the stack, but g and sameAsG point to the SAME heap object. Mutating through one affects the other.

This is “pass by reference” — objects are essentially passed around by their pointer. Primitives (numbers, strings) are passed by VALUE (copied).

function addOne(x) {
  x = x + 1;                  // local change; doesn't affect caller
  return x;
}
let n = 5;
addOne(n);                   // returns 6
n;                            // still 5 — primitive was COPIED
 
function addProp(o) {
  o.foo = "bar";              // mutates the heap object
}
const obj = {};
addProp(obj);
obj.foo;                     // "bar" — same heap object, change visible

Same syntax; vastly different behavior. Knowing the underlying memory model makes this make sense.


Garbage collection — the cleanup

When an object becomes UNREACHABLE (no variables, no other objects reference it), the garbage collector frees it.

How does the GC know?

JavaScript uses tracing garbage collection (specifically, a generational variant called “Orinoco” in V8):

  1. Start from “roots” — global variables, the call stack
  2. Trace every reference outward
  3. Any object NOT reached is unreachable
  4. Free unreachable objects’ memory

This runs periodically. You don’t trigger it; you don’t control it.

function makeAndForget() {
  const big = new Array(1000000).fill(0);     // 1M zeros on heap
  return null;                                  // `big` reference is dropped
}
makeAndForget();
// After this, `big` is unreachable.
// The GC will eventually reclaim the 1M-item array.

The DON’T:

  • Don’t explicitly null-out variables to “help” the GC. Modern engines handle this. Premature optimization.
  • Don’t count on WHEN garbage collection runs. It’s non-deterministic.

Memory leaks in JavaScript

A “memory leak” is heap memory that should be freed but isn’t — because something is still REFERENCING it.

Common JavaScript leak patterns:

1. Forgotten event listeners

function setup() {
  const data = new Array(1000000);
  document.addEventListener("click", () => {
    console.log(data.length);     // closure holds `data` alive forever
  });
}

The event listener is registered globally. The closure captures data. Even when setup() returns, the listener keeps data alive. If setup() is called repeatedly without removing old listeners, the heap grows unboundedly.

Fix: store a reference to the handler and remove it when done.

const handler = () => { ... };
document.addEventListener("click", handler);
// later:
document.removeEventListener("click", handler);

2. Forgotten timers / intervals

setInterval(() => doStuff(largeData), 1000);
// largeData is referenced by the interval forever

Fix: store the interval ID and clear it.

const id = setInterval(...);
clearInterval(id);

3. Closures retaining things

function makeCounter() {
  const bigData = loadHugeData();    // 100MB
  let count = 0;
  return () => count++;               // closure has access to bigData even though not used
}
const counter = makeCounter();
// bigData is still alive because the closure CAN reach it.

Modern engines are smart about closures (they don’t actually retain unused variables in many cases) but old engines and complex closures can.

4. Detached DOM nodes

const element = document.getElementById("container");
// later:
container.remove();           // removed from DOM
// but if `element` is still in a JS variable, the node stays in memory.

The DOM tree is on the heap. Removing a node from the document doesn’t free it if any JS variable still references it.

5. Caches without eviction

const cache = new Map();
function processed(input) {
  if (cache.has(input)) return cache.get(input);
  const result = heavyWork(input);
  cache.set(input, result);            // grows forever
  return result;
}

Fix: use a bounded cache (LRU library) or WeakMap if keys are objects.

For Bible Quest-style projects, memory leaks rarely show up in normal usage. They appear with:

  • Long-running pages (single-page app that stays open for hours)
  • Infinite loops, recursive component re-renders
  • Event-listener-heavy code

The browser’s DevTools Memory tab profiles heap usage; “Take heap snapshot” + compare before/after = detect leaks.


Stack overflow — what it is, what causes it

JavaScript’s stack is bounded — typically 10,000-15,000 frames in modern engines. When recursion exceeds this:

Uncaught RangeError: Maximum call stack size exceeded

Causes:

  • Recursive function without base case → infinite recursion → instant overflow
  • Recursive function with VERY deep input (parsing 100k-deep JSON, walking a million-node linked list)
  • Mutual recursion (A calls B calls A) without termination
  • Accidentally infinite component re-rendering in React

Fixes:

  • Add / verify base case
  • Convert to iteration with explicit stack
  • Use trampolining (not common in JS)
  • Reduce input depth

See Recursion for the full picture.


Common gotchas

  • Primitives are copied; objects are referenced. This is the #1 source of “why did this variable change?!” bugs. Same syntax (= assignment), different semantics.

  • const doesn’t make an object immutable. const obj = {}; means obj can’t be reassigned to point at a different object — but obj.foo = "bar" is fine. The REFERENCE is constant; the OBJECT isn’t.

  • Closures hold references, not values. setTimeout(() => console.log(x), 1000) captures x by reference. If x changes before the timeout fires, you see the new value.

  • let i inside a for loop has block scope. Each iteration creates a new i. Setting timeouts inside the loop captures DIFFERENT i values (this works as expected). But var i would capture the SAME i across all iterations — classic JS bug.

  • The arguments object in non-arrow functions is mutable. Avoid it. Use rest parameters (...args) instead.

  • Spread creates SHALLOW copies. {...obj} copies top-level properties; nested objects are still references. For deep copies: structuredClone(obj).

  • JSON.parse(JSON.stringify(obj)) is the old-school deep clone. Loses functions, dates, undefined, Maps, Sets, circular refs. structuredClone is better.

  • Objects passed to functions can be MUTATED inside. “Pure functions” don’t do this; many real-world functions do. Be aware.

  • Object.freeze only freezes one level deep. Nested objects can still be mutated.

  • The garbage collector is non-deterministic. Don’t write code that depends on WHEN it runs.

  • WeakMap and WeakSet hold weak references. Keys can be garbage-collected even while in the map. Useful for caches that shouldn’t prevent collection.

  • Map and Set hold strong references. Keys + values stay alive while in the map.

  • Iframes and Web Workers have separate heaps. They can pass messages (via postMessage), but objects aren’t shared.

  • SharedArrayBuffer shares memory between Workers — but with concurrency primitives (Atomics). Not for casual use.

  • Memory in browser DevTools is a snapshot. It doesn’t show what’s “leaking”; it shows what’s CURRENTLY ALLOCATED. Compare two snapshots (before/after an action) to find leaks.

  • Performance.memory is non-standard. Chrome exposes it; Firefox / Safari don’t (or partial). Don’t rely on it cross-browser.

  • V8 has multiple GC strategies. “Scavenge” for young objects (cheap); “Mark-Sweep” for old objects (more expensive). Pause times have improved dramatically over years.

  • A “memory leak” in JS doesn’t crash; it slows. Browsers refuse new allocations only when the heap hits the OS-allocated limit (1-4 GB typically). Before that, performance degrades but the app continues.

  • Strings in JavaScript are immutable. Concatenating creates a new string. For huge string-building, use array + join.

  • new Date() creates a heap object. Comparing dates with == compares references, not values. Use .getTime() or .toISOString().

  • Functions are objects. function foo() {} puts foo on the heap. Reassigning a property (foo.bar = 1) is legal but unusual.

  • typeof null === 'object'. A famous JS quirk; null is treated as a special object reference value for legacy reasons.

  • Arrays in JavaScript are SPECIAL objects, not primitive arrays. They’re heap-allocated; the runtime may optimize to native arrays under the hood for dense numeric data.

  • Stack size limits depend on engine. V8 limits ~10k-15k frames; Firefox slightly different. Don’t rely on a specific number.

  • AI-generated code can introduce subtle leaks. Especially around event listeners and intervals. Periodically profile with DevTools.

  • Performance.measure / Performance.mark help profile memory + CPU. Better than console.log for finding bottlenecks.

  • Don’t optimize memory before profiling. Modern engines handle most allocation patterns efficiently. Optimize when DevTools shows a real problem.

  • structuredClone(obj) is the modern deep clone. Available in Node 17+, all modern browsers. Handles cyclic references, Maps, Sets, Dates, ArrayBuffers, blobs.

  • Large strings can stress the heap. Reading a 100MB file into a single string consumes 200MB on the heap (UTF-16 internally in many engines). Stream large files.

  • Memory limits in serverless functions matter. Vercel default: 1-3 GB depending on tier. A function that allocates a 500MB object on each invocation may exceed limits at scale.

  • React’s component tree lives on the heap. Each render creates new VDom objects; React reconciles + frees the old. Heavy re-rendering = GC pressure.

  • The “stop the world” GC pause was the bad old days. Modern V8 mostly runs GC concurrently; pauses are milliseconds. Users rarely notice.

  • In Node.js, max heap is configurable. node --max-old-space-size=8192 (in MB). Default ~4GB. Webapps rarely need more; data-processing scripts sometimes do.


When this stuff actually matters

For typical Bible Quest-scale projects: rarely. The runtime handles everything well.

It matters when:

  • Building a single-page app that runs for hours (memory leaks compound)
  • Processing very large data (streaming + chunking instead of loading everything)
  • Hot paths with millions of allocations per second (object pooling, reused buffers)
  • Long-running Node services (heap growth analysis matters)
  • Embedded / mobile environments with tight memory (less common in JS)

For everyone else: write clear code, profile when you see slowness, and trust the runtime.


See also


Sources