CS Foundations

How a Computer Runs Your Program

From source code to machine code: compiling, interpreting and JIT, the stack and the heap, function calls, stack overflow and garbage collection.

Lesson 0020 min read#computer-science#memory#runtime

On this page

  1. Words used in this lesson
  2. From source code to something the CPU can run
  3. The stack: one frame per call
  4. The heap: where objects live
  5. Addresses, pointers and references
  6. Garbage collection in one page
  7. Interview questions
  8. Key takeaways
  9. What's next

In one line: your JavaScript is never run as you typed it. The engine turns it into bytecode, then (for hot code) into machine code. Function calls pile up on a small stack, objects live on a big heap, and a garbage collector cleans the heap. Most "weird" errors, such as stack overflow or a memory leak, come straight from this picture.

Interviewers assume you know what happens between pressing Enter on node app.js and the CPU doing something. They rarely ask "explain the V8 pipeline". They ask "why does this recursion crash?", "why is this object still in memory?" or "what is the difference between passing a number and passing an object?". All three answers live here.

This lesson goes from source code to machine code, then to the stack, the heap, pointers and garbage collection. Every program and number below was run on Node 22.22.0 (V8 12.4). For the language-level story of the call stack and the engine, see JavaScript Lesson 2 and JavaScript Lesson 13. This lesson is the machine-level view that sits under them.

What I ran / checked. All code blocks were run in Node v22.22.0 (V8 12.4.254.21-node.33) on a 4-core Linux machine, and the output shown is what I got. Numbers such as stack depth and milliseconds are from this machine ("local"). They will differ on yours, and that is the point. For the V8 pipeline I read the V8 website sources on GitHub (v8/v8.dev): the Ignition page, the Sparkplug and Maglev posts and the "Trash talk" garbage collection post. I did not read V8 source code, so details below the level of those pages (exact thresholds for tier-up) are not claimed.

Words used in this lesson

  • Machine code is the raw instructions a CPU can run directly. Each CPU family (x86-64, ARM64) has its own.
  • Bytecode is a compact, simple list of instructions for a virtual machine (software that pretends to be a CPU). It is not tied to one real CPU.
  • A compiler turns code into another form before it runs. An interpreter runs code step by step, without first making machine code for the whole program.
  • JIT (just-in-time) compilation means compiling while the program is already running, using what has been learned about how the code behaves.
  • An AST (abstract syntax tree) is the program as a tree of objects, such as "add node with two children". The parser builds it from text.
  • A deopt (deoptimisation) is when the engine throws away machine code it made earlier, because an assumption turned out false, and goes back to a slower tier.
  • The stack (call stack) is a small area of memory that stores one block per running function call. A frame is that block.
  • The heap is a large area of memory for data whose lifetime is not tied to one call, such as objects and arrays.
  • A pointer is a value that holds a memory address. A reference is the safe version of this idea in languages like JavaScript: you can use it to reach an object, but you cannot do arithmetic on it or see the address.
  • Garbage collection (GC) is automatic cleaning: the runtime finds objects that nothing can reach any more and frees their memory.
  • Reachable means you can get to an object by starting from the roots (global variables, the stack) and following references.

From source code to something the CPU can run

A CPU understands only machine code. Your .js file is text. Something has to bridge the gap, and there are three classic ways.

ApproachWhat happensGoodBadExample
Ahead-of-time compileThe whole program becomes machine code before it runsFast to run, no start-up workSlow build, one binary per CPUC, Rust, Go
InterpretA program reads your code (or bytecode) and does each step itselfStarts at once, simpleSlow per stepEarly JavaScript engines, many scripts
JIT compileStart by interpreting, watch what runs often, compile that part to machine code while runningFast start and fast hot codeComplex, uses memory and CPU to compileV8, Java's HotSpot

Modern JavaScript engines use the third way. The reason is that JavaScript is very dynamic: in a + b the engine does not know in advance if a and b are numbers or strings. A JIT can watch the program run, see that a and b were always small integers, and make fast machine code for that case, with a guard that checks the guess.

What V8 does, in outline

V8 is the engine inside Chrome and Node. This is the outline given in the V8 pages I read, not every detail:

  1. Parse. Read the text and build an AST. Functions that never run are only pre-checked, not fully parsed. (This laziness is described in V8 material generally. I did not measure it here.)
  2. Ignition makes bytecode from the AST and interprets it. The V8 docs call Ignition "a fast low-level register-based interpreter".
  3. Sparkplug is a "non-optimizing" compiler. The V8 post says it is designed to compile "so fast, that we can pretty much compile whenever we want". It turns bytecode into machine code with little analysis.
  4. Maglev is a mid-tier optimising compiler (it does some optimisation, but is cheaper than TurboFan).
  5. TurboFan is the top-tier optimising compiler. It uses feedback from earlier runs to make the fastest code, with guards.
  6. If a guard fails, V8 deoptimises: it drops the optimised code and returns to a lower tier.

The names and the order are from the V8 sources above. Which function moves up when is decided by heuristics (rules of thumb inside V8) that change between versions, so do not memorise thresholds.

See the bytecode

Node can print the bytecode of a function. I ran this file (add.js):

JavaScript
function add(a, b) { return a + b; }let t = 0;for (let i = 0; i < 200000; i++) t = add(t, i);console.log(t);add("x", 1);
Shell
node --print-bytecode --print-bytecode-filter=add add.js
TEXT
[generated bytecode for function: add (0x0739d265af29 <SharedFunctionInfo add>)]Bytecode length: 6Parameter count 3Register count 0Frame size 0   21 S> 0x445a17c1ed8 @    0 : 0b 04             Ldar a1   30 E> 0x445a17c1eda @    2 : 3b 03 00          Add a0, [0]   34 S> 0x445a17c1edd @    5 : ae                Return

Read it like this: load argument b into the accumulator (Ldar a1), add argument a to it (Add a0), return. "Parameter count 3" includes the hidden this. That is all a + b is at this level: three tiny steps. The [0] after Add is a feedback slot, a place where V8 records which types it saw. That record is what the optimiser later uses.

Watch it tier up, then fall back

Same file, with tracing flags:

Shell
node --trace-opt --trace-deopt add.js

Lines I got (shortened):

TEXT
[marking ... <JSFunction add ...> for optimization to TURBOFAN, ConcurrencyMode::kConcurrent, reason: hot and stable][completed optimizing ... <JSFunction add ...> (target TURBOFAN)][bailout (kind: deopt-eager, reason: not a Number or Oddball): begin. deoptimizing ... <JSFunction add ...>

add ran 200,000 times with numbers, so V8 marked it "hot and stable" and optimised it with TurboFan. Then I called add("x", 1). The optimised code assumed numbers. The guard failed with "not a Number or Oddball", and V8 threw the machine code away. Output also showed an earlier deopt with reason "not a Smi" (a Smi is a small integer, which V8 stores in a fast special way) followed by another optimise, which shows the engine adjusting its guess as it learns. The takeaway: stable types make fast code. A function that sometimes gets numbers and sometimes strings gives the optimiser less to rely on.

How much does the JIT matter?

I ran the same loop three ways. The file:

JavaScript
function sum(n){ let t=0; for(let i=0;i<n;i++) t+=i%7; return t; }const s=process.hrtime.bigint(); const r=sum(1e8); const ms=Number(process.hrtime.bigint()-s)/1e6;console.log(r, ms.toFixed(0)+" ms");
CommandResult (local)
node jit.js299999995 in 388 ms
node --jitless jit.js299999995 in 2454 ms
node --no-opt --no-maglev --no-sparkplug jit.js299999995 in 2381 ms

On this machine, 100 million loop steps took about 0.39 s with all tiers and about 2.4 s with only the interpreter, roughly six times slower. One run each, so treat the ratio as "several times", not as a law. (The --jitless run printed a harmless warning about WebAssembly being disabled.)

The stack: one frame per call

The call stack is where the program remembers "where was I?". When a function is called, the machine pushes a frame on the stack. A frame holds the arguments, the local variables, and the address to come back to when the function returns. When the function returns, its frame is popped and the caller continues.

TEXT
call a()  ->  [a]call b()  ->  [a][b]call c()  ->  [a][b][c]      <- c runs nowc returns ->  [a][b]

You can see the stack in an error. I ran:

JavaScript
function c(){ console.log(new Error("here").stack.split("\n").slice(0,4).join("\n")); }function b(){ c(); } function a(){ b(); } a();
TEXT
Error: here    at c (/path/stack.js:1:27)    at b (/path/stack.js:2:15)    at a (/path/stack.js:2:36)

(I replaced the long folder name with /path.) The stack trace is just the stack, printed from the top (newest) frame down. Two facts follow:

  • Stack memory is fast (pushing and popping is just moving one pointer) and automatic (no cleaning needed).
  • Stack memory is small and fixed. The thread gets a limited size when it starts.

Stack overflow

If calls never return, frames keep piling up until the stack is full. JavaScript then throws a RangeError. I ran:

JavaScript
let depth = 0;function down() { depth++; down(); }try { down(); } catch (e) { console.log(e.constructor.name, "-", e.message, "- depth:", depth); }// frames with more localslet d2 = 0;function down2(a,b,c,d,e,f) { const x=a+b,y=c+d,z=e+f; d2++; down2(x,y,z,a,b,c); }try { down2(1,2,3,4,5,6); } catch (e) { console.log("more locals depth:", d2); }
TEXT
RangeError - Maximum call stack size exceeded - depth: 12555more locals depth: 5022

Notice the second number is much smaller. The function with six arguments and three locals has a bigger frame, so fewer fit. The limit is memory, not a count of calls. Node lets you raise the limit with a flag. With node --stack-size=2000 l0a.js the same file printed depth 25560 and 10224. (That flag is a Node/V8 option. Raising it can crash the process if it goes past what the operating system gave the thread, so do not rely on it in production.)

The depth number you get depends on the Node version, the machine and how big the frames are. Never write a program that depends on a depth like 12,555. The honest rule is "about ten thousand simple calls".

Recursion and the stack

Recursion is a function calling itself. Each call gets its own frame, so recursion depth costs memory (DSA Lesson 14 introduces recursion). A recursive factorial of a huge number overflows, while the loop version does not. I ran:

JavaScript
const fact = n => n<=1n?1n:n*fact(n-1n);console.log(String(fact(20n)));const iter = n => { let r=1n; for(let i=2n;i<=n;i++) r*=i; return r; };console.log(iter(5000n).toString().length, "digits via loop");try { fact(100000n) } catch(e){ console.log(e.message) }
TEXT
243290200817664000016326 digits via loopMaximum call stack size exceeded

(n after a number makes it a BigInt, a number type with no size limit. See Lesson 2.) Some languages remove the stack growth in "tail calls". The JavaScript spec describes tail call optimisation, but V8 does not do it, which is why this overflowed. Do not count on it in JavaScript. For deep recursion, use a loop or your own array as a stack.

Stack overflow in interviews is usually about recursion depth. If an input can be 100,000 deep (a linked list, a skewed tree, a long chain in a graph), a recursive DFS may crash even though the algorithm is correct. Say this out loud, and offer the iterative version with an explicit stack.

The heap: where objects live

The stack holds small, short-lived things. Objects, arrays, strings and closures can grow, can be shared, and can outlive the function that made them. They go on the heap, a big region that the engine manages. A local variable that holds an object holds only a reference to it; the object itself is on the heap.

TEXT
STACK (frame of f)            HEAP  user  ───────────────────▶  { name: "Asha", tags: [ ──▶ array ] }  count = 3   (small number, stored directly)

(In V8, small integers are stored right in the value, called Smis, and most other values are heap objects. This is an engine detail. For reasoning, "primitives are copied, objects are shared through references" is enough.)

Measuring the heap

process.memoryUsage() shows how much memory Node uses. I made a million small objects and kept them:

JavaScript
const mb = n => (n/1048576).toFixed(1)+" MB";const before = process.memoryUsage();const keep = [];for (let i=0;i<1e6;i++) keep.push({id:i, name:"user"+i});const after = process.memoryUsage();console.log("heapUsed before", mb(before.heapUsed), "after 1e6 objects", mb(after.heapUsed));const ta = new Float64Array(1e6);const a2 = process.memoryUsage();console.log("Float64Array(1e6) = ", ta.byteLength, "bytes; arrayBuffers", mb(a2.arrayBuffers));console.log("heapUsed after typed array", mb(a2.heapUsed));
TEXT
heapUsed before 3.5 MB after 1e6 objects 90.5 MBFloat64Array(1e6) =  8000000 bytes; arrayBuffers 7.6 MBheapUsed after typed array 90.8 MB

A million tiny objects, each with an id and a short string, cost about 87 MB: roughly 90 bytes per object on this machine. A Float64Array of a million numbers cost exactly 8 million bytes (8 bytes each), and it was counted under arrayBuffers, not in the JS heap (heapUsed barely moved). That second fact matters: typed arrays store raw bytes in a flat block, with no per-element object. This is why they are compact and fast (Lesson 1 shows why flat memory is also faster to read).

Typed arrays are views on bytes

An ArrayBuffer is a block of raw bytes. A typed array is a view that reads those bytes as numbers of one type. Two views on the same buffer see the same memory:

JavaScript
const buf = new ArrayBuffer(8); const u8=new Uint8Array(buf); const u32=new Uint32Array(buf);u32[0]=0x01020304; u32[1]=1; console.log(u8, u32);const v = new Uint8Array(buf, 2, 4); v[0]=255; console.log(u8, v.byteOffset);
TEXT
Uint8Array(8) [  4, 3, 2, 1,  1, 0, 0, 0] Uint32Array(2) [ 16909060, 1 ]Uint8Array(8) [  4, 3, 255, 1,  1, 0,   0, 0] 2

I wrote one 32-bit number 0x01020304 and read the same bytes back as 4, 3, 2, 1. The lowest byte comes first on this machine. That ordering is called endianness, and Lesson 4 covers it. The third line shows that writing through the view v (which starts at byte 2) changed the third byte of u8: same memory, three names.

Addresses, pointers and references

Every byte of memory has a number, its address. In C you can hold an address in a variable (a pointer), add 1 to move to the next item, and read or write the bytes there. That is powerful and dangerous: a wrong address means a crash or a security hole.

JavaScript gives you references instead. A reference leads to an object, but you cannot see the address, add to it, or point into the middle of something. The engine is also free to move objects (V8's collector copies survivors, see below), so an address would not stay valid anyway.

The result: assignment copies the reference, not the object. I ran:

JavaScript
const a = {n:1}; const b = a; b.n = 2; console.log(a.n);function f(o){ o.n = 99; o = {n:-1}; } f(a); console.log(a.n);let s1="hi", s2=s1; s2+="!"; console.log(s1,s2);console.log({}==={}, [1]===[1]);
TEXT
299hi hi!false false
  • b = a copies a reference. Both names point at one object, so changing b.n changed what a sees (2).
  • Inside f, o.n = 99 follows the reference and changes the shared object. o = {n:-1} only changes the local copy of the reference, so the caller's a is unchanged. This is why people say "JavaScript passes by value, and for objects the value is a reference". It is sometimes called "call by sharing".
  • Strings (and numbers, booleans, and so on) are primitives. They cannot be changed in place, so s2 += "!" makes a new string and s1 is untouched.
  • === on objects compares references, not contents. Two separate {} are two objects, so they are not equal.
IdeaC pointerJavaScript reference
HoldsA memory addressA handle to an object
Can you see the number?YesNo
Pointer arithmetic?YesNo
Can it be dangling (point to freed memory)?YesNo, the object stays alive while reachable
Can the object move?No (usually)Yes, the engine can move it

Garbage collection in one page

On the stack, a frame is freed when the function returns. On the heap, nothing says when an object is finished. Some languages make you free it (C: free). JavaScript uses a garbage collector.

The idea: reachability. Start from the roots (global variables, local variables in frames on the stack). Follow every reference. Everything you can reach is alive. Everything else is garbage and its memory can be reused. This is called mark-and-sweep (mark what is reachable, free the rest). A plain reference count is not used, because two objects that point at each other would never be freed.

The generational trick. The V8 post "Trash talk" explains the "generational hypothesis": most objects die young. So V8 splits the heap into a young generation and an old generation:

  • The minor GC (Scavenger) cleans only the young generation. It copies the survivors to a new area. That is cheap because few survive.
  • The major GC (Mark-Compact) cleans the whole heap. It is more work, so it runs less often.
  • Objects that survive for a while are moved to the old generation.

The post also describes parallel and concurrent work (GC work split over several threads, or done while your code runs) to keep pauses short. I read that in the post; I did not test pause times here.

Watching an object get collected

WeakRef holds a reference that does not keep an object alive. I started Node with --expose-gc so I could request a collection by hand (this is for experiments only):

JavaScript
let big = {data:new Array(1e6).fill(0)};const wr = new WeakRef(big);console.log("alive before:", wr.deref()!==undefined);big = null;setTimeout(()=>{ global.gc(); setTimeout(()=>console.log("alive after gc:", wr.deref()!==undefined),0)},0);
TEXT
alive before: truealive after gc: false

After big = null, nothing could reach the object, so the forced collection freed it. (The spec says a WeakRef target is not collected during the same turn of the event loop in which it was read. That is why the check waits with setTimeout.)

Memory leaks in a garbage-collected language

GC frees what is unreachable, not what is unneeded. A leak in JavaScript means you are still holding a reference you forgot about. Common sources:

  • A global Map or array used as a cache that is never trimmed.
  • Event listeners that are added and never removed.
  • Closures that capture a large object and live a long time (JavaScript Lesson 10).
  • Timers (setInterval) that are never cleared.
JavaScript
const cache = new Map();for (let i=0;i<5;i++){ cache.set(i,new Array(1e5).fill(i)); }console.log("cache keeps", cache.size);
TEXT
cache keeps 5

All five arrays are reachable through cache, so they will never be freed until you delete the entries. Fixes: bound the size (for example with an LRU cache, which drops the least recently used entry), use WeakMap when the key is an object that should be allowed to die, and always pair "add listener" with "remove listener".

Which is faster, stack or heap? Stack allocation is just moving a pointer, so it is very cheap. Heap allocation needs a free spot, and later cleaning work. That is why engines try to keep short-lived values off the heap (TurboFan has an analysis called escape analysis for this, which I mention as a name only, I did not test it). For you, the practical rule is "do not create millions of tiny objects in a hot loop if a typed array or a few numbers would do".

Interview questions

Is JavaScript compiled or interpreted? (Must know)

Both, and the question is slightly wrong. The language does not say. In V8 the code is parsed, turned into bytecode and interpreted by Ignition, and hot functions are compiled to machine code while the program runs (JIT). I can show it: node --trace-opt prints a function being optimised after many calls. The JIT matters: in my test the same loop was about six times slower with only the interpreter.

What is a JIT and why do JavaScript engines use one? (Must know)

JIT means compiling while the program runs. JavaScript is dynamic, so the engine does not know the types ahead of time. It starts by interpreting and records the types it sees. For hot code it makes machine code that assumes those types, with guards. If a guard fails, it deoptimises and goes back to the interpreter. This gives quick start-up and fast hot loops.

What is a deopt and how can your code cause one? (Senior signal)

A deopt is V8 discarding optimised code because an assumption broke. I showed it with a function that was called 200,000 times with numbers and then once with a string: the trace printed a bailout with reason "not a Number or Oddball". To avoid deopts in hot code, keep types stable: same shape of objects, same kinds of arguments. I would only worry about this after a profile shows a hot function, not before.

What is the call stack and what is in a frame? (Must know)

The call stack is the memory that tracks active function calls. Each call pushes a frame with the arguments, local variables and the place to return to. Returning pops the frame. A stack trace is a printout of the frames.

Why does "Maximum call stack size exceeded" happen, and how do you fix it? (Must know)

Calls that do not return keep adding frames until the fixed-size stack is full. Usually it is a missing or wrong base case, or valid recursion that is too deep. On my machine a one-argument function overflowed at about 12,500 frames and a six-argument one at about 5,000, since bigger frames mean fewer fit. Fixes: correct the base case, turn recursion into a loop with an explicit stack, or reduce the depth. Raising --stack-size is a workaround, not a fix.

Does JavaScript have tail call optimisation? (Trap)

The language spec describes it, but V8 does not implement it, and my test showed the recursive factorial overflowing. So I never rely on it. I use a loop for long chains.

What lives on the stack and what lives on the heap? (Must know)

The stack holds frames: arguments, locals, return addresses. The heap holds objects, arrays, closures and other data that can outlive a call or be shared. A local variable that "holds an object" actually holds a reference on the stack to an object on the heap. In V8 the real layout has details (small integers are stored directly in the value), but this model predicts behaviour correctly.

Is JavaScript pass by value or pass by reference? (Trap)

Pass by value, where for objects the value is a reference (call by sharing). The example: inside a function, o.n = 99 changes the caller's object, but o = {n: -1} does not change the caller's variable. A real pass by reference would let the function re-point the caller's variable.

What is the difference between a pointer and a reference? (Senior signal)

A pointer is an address you can see and do arithmetic on, and it can dangle. A reference is a handle to an object. You cannot see the address or add to it, and the object cannot be freed while it is reachable. JavaScript has only references, which also lets the engine move objects during garbage collection.

How does garbage collection decide what to free? (Must know)

By reachability. From the roots (globals and the stack), follow all references. Everything reached is live, the rest is garbage. V8 is generational: a minor GC (Scavenger) cleans the young generation often and cheaply, because most objects die young, and a major GC (Mark-Compact) cleans the whole heap less often.

Can JavaScript leak memory? Give an example and a fix. (Must know)

Yes, when you keep a reference you no longer need. Example: a module-level Map used as a cache with no limit, which in my test kept all five 100,000-element arrays alive. Fix: limit its size (LRU), delete entries, use a WeakMap for object keys, remove event listeners, clear timers. To find a leak I take heap snapshots in DevTools or Node's inspector, and compare two of them to see what grows.

When would you use a typed array instead of a normal array? (Senior signal)

When I have many numbers of one type and care about memory or speed: binary data, image or audio buffers, large numeric tables. A Float64Array of a million numbers took exactly 8,000,000 bytes and sat outside the JS heap, while a million small objects took about 87 MB on the heap. The trade-offs: fixed length, one element type, and no push.

Why can a correct recursive DFS still crash in production? (Trap)

Because depth is limited by stack memory, not by correctness. A path-like input of 100,000 nodes can overflow even though the algorithm is right. I would say so in the interview, and write it iteratively with an explicit stack (an array that I push to and pop from) if the constraints allow deep inputs.

Key takeaways

  • JavaScript runs through parse, bytecode (Ignition), then optional machine-code tiers (Sparkplug, Maglev, TurboFan). Stable types make optimised code stay optimised.
  • The stack holds one frame per active call. It is small and its limit depends on frame size, so deep recursion fails with RangeError.
  • The heap holds objects. Variables hold references to them. Assignment and argument passing copy the reference, not the object.
  • The garbage collector frees what is unreachable. It does not free what you merely forgot about, so unbounded caches and forgotten listeners leak.
  • Typed arrays are flat blocks of bytes, compact and outside the JS heap. Several views can share one buffer.
  • Numbers like stack depth, bytes per object and milliseconds are local measurements. Say "about" and "on my machine".

What's next

Lesson 1: Memory, CPU Caches and Why Array Order Matters. You now know that data lives in memory. Next we look at how fast the CPU can reach it, and why the same loop can be several times slower just because it walks memory in a different order.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Related series