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.
On this page
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.
| Approach | What happens | Good | Bad | Example |
|---|---|---|---|---|
| Ahead-of-time compile | The whole program becomes machine code before it runs | Fast to run, no start-up work | Slow build, one binary per CPU | C, Rust, Go |
| Interpret | A program reads your code (or bytecode) and does each step itself | Starts at once, simple | Slow per step | Early JavaScript engines, many scripts |
| JIT compile | Start by interpreting, watch what runs often, compile that part to machine code while running | Fast start and fast hot code | Complex, uses memory and CPU to compile | V8, 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:
- 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.)
- Ignition makes bytecode from the AST and interprets it. The V8 docs call Ignition "a fast low-level register-based interpreter".
- 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.
- Maglev is a mid-tier optimising compiler (it does some optimisation, but is cheaper than TurboFan).
- TurboFan is the top-tier optimising compiler. It uses feedback from earlier runs to make the fastest code, with guards.
- 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):
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);node --print-bytecode --print-bytecode-filter=add add.js[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 ReturnRead 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:
node --trace-opt --trace-deopt add.jsLines I got (shortened):
[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:
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");| Command | Result (local) |
|---|---|
node jit.js | 299999995 in 388 ms |
node --jitless jit.js | 299999995 in 2454 ms |
node --no-opt --no-maglev --no-sparkplug jit.js | 299999995 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.
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:
function c(){ console.log(new Error("here").stack.split("\n").slice(0,4).join("\n")); }function b(){ c(); } function a(){ b(); } a();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:
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); }RangeError - Maximum call stack size exceeded - depth: 12555more locals depth: 5022Notice 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:
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) }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.
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:
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));heapUsed before 3.5 MB after 1e6 objects 90.5 MBFloat64Array(1e6) = 8000000 bytes; arrayBuffers 7.6 MBheapUsed after typed array 90.8 MBA 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:
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);Uint8Array(8) [ 4, 3, 2, 1, 1, 0, 0, 0] Uint32Array(2) [ 16909060, 1 ]Uint8Array(8) [ 4, 3, 255, 1, 1, 0, 0, 0] 2I 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:
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]);299hi hi!false falseb = acopies a reference. Both names point at one object, so changingb.nchanged whatasees (2).- Inside
f,o.n = 99follows the reference and changes the shared object.o = {n:-1}only changes the local copy of the reference, so the caller'sais 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 ands1is untouched. ===on objects compares references, not contents. Two separate{}are two objects, so they are not equal.
| Idea | C pointer | JavaScript reference |
|---|---|---|
| Holds | A memory address | A handle to an object |
| Can you see the number? | Yes | No |
| Pointer arithmetic? | Yes | No |
| Can it be dangling (point to freed memory)? | Yes | No, 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):
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);alive before: truealive after gc: falseAfter 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
Mapor 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.
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);cache keeps 5All 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
- Memory Leaks in Single-Page Apps · Scenario Debugging
- Memory, Garbage Collection and WeakMap/WeakRef · JavaScript Part 2
- What Node.js Is and How It Runs Your Code · Node.js From Zero
Related series