Stack frames & calling conventions
Learn how V8 uses stack frames for JavaScript calls, what arguments and locals live there, and how optimized and inlined frames stay debuggable.
- 01Draw calls as framesShow how the caller waits, the called function gets its own frame, and the caller goes on from the line saved in the return address.
- 02Name the main slotsTell apart the return address, the arguments, the local variables, the context, and the slots that fast code uses.
- 03Tell language rules from engine choicesKnow which facts are JavaScript rules, like missing and extra arguments, and which are V8's own choices, like bytecode, stack size, and copied-in functions.
Each call gets its own record
When one function calls another, the first function stops and waits. The function that makes the call is the caller. The function being called is the callee.
The engine is the program that runs your JavaScript. In Chrome and Node, the engine is called V8. While the caller waits, the engine must remember a few things:
- where to go back to in the caller,
- the values passed in, called the arguments,
- the variables made inside the call, called the local variables,
- the outer variables the function can still use.
A stack frame is the record for one function call that is still running. It holds where to go back to, the arguments, the local variables, and links to other things the engine needs. When the call ends, its frame is thrown away.
You met the simple version of this in Call stack, Execution context, and Stack and heap. This lesson keeps that simple picture and adds real facts about V8. The last lesson, Heap layout, showed where long-lived values are kept. This lesson is about the short-lived records made while calls run.
Picture a pile of plates in your kitchen. You put a plate on top when you need it. When you are done, you clear the top plate first. Function calls work the same way: the last call to start is the first to end.
- In real life: You put a plate on top when you need it
- In JavaScript: A frame goes on top when a function starts
- In real life: A note on the plate says where it goes back
- In JavaScript: The return address says where the caller goes on
- In real life: The food on the plate is only for this meal
- In JavaScript: Local variables belong to this one call
- In real life: You clear the top plate first
- In JavaScript: The newest call ends first
You don't need to learn every byte of V8's frames. After this lesson you can:
- read a stack trace, the list of calls printed with an error,
- explain what happens with missing and extra arguments,
- explain why fast, optimized code can still be debugged,
- see where the next lesson, on stack overflow, starts.
The machine stack and the JavaScript stack
There are two stacks to keep apart:
- The machine stack is a block of the computer's memory. V8's own code and the fast code it makes for your functions use it.
- The JavaScript stack is the list of your JavaScript calls that are still running. You see it in DevTools and in an error's stack text.
In V8 the two are linked, but they are not the same thing.
In Node, V8 keeps JavaScript calls on the machine stack, and that stack has a fixed size. Node's --stack-size flag sets the size. A flag is an option you type after node on the command line.
The code below has a function that calls itself again and again. This is called recursion. It counts the calls until the engine runs out of room and throws a RangeError.
let depth = 0;function dive() { depth += 1; dive();} try { dive();} catch (error) { console.log(error.name, depth);}Line 1 starts a counter at 0. Line 3 adds 1 on every call. Line 4 calls dive again, so it never stops on its own. Line 8 makes the first call. Line 9 catches the error, and line 10 prints its name and the count.
The lesson's test runs this code twice: once with a small stack size and once with a bigger one. The bigger size always reaches more calls before the RangeError. The exact count changes from computer to computer, so the test doesn't check it. The next lesson is all about stack limits.
Four words in this lesson sound alike. The table says what each one means and what it keeps. Read each row from left to right.
| Word | What it means | What it keeps |
|---|---|---|
| Machine stack | A block of the computer's memory that grows and shrinks as code calls functions. | The real frames for V8's own code and for your JavaScript calls. |
| JavaScript stack | The list of your JavaScript calls that are still running. | What you see in Error().stack, in DevTools, and in this lesson's pictures. |
| Heap | Memory for things that can last longer than one call. The garbage collector cleans it up. | Objects, arrays, strings, a closure's outer variables, and many of V8's notes. |
| Stack trace | Text that lists the calls at one moment, built from frames. | A view for debugging. It is not a copy of memory. |
What a stack frame holds
Let's follow a small checkout. The cart costs ₹150. The shop adds 18% GST, a tax on the price, so the total is ₹177. The code has three functions, and each one calls the next: checkout calls total, and total calls addTax.
Step through three calls in a simple model. It shows when each frame is added, which slots matter, and when each frame is removed.
script
function addTax(amount) { const rate = 0.18; const tax = amount * rate; return amount + tax;} function total(items) { let sum = 0; for (const item of items) { sum += item.price; } return addTax(sum);} function checkout(cart) { const finalAmount = total(cart); return `Pay ₹${finalAmount.toFixed(2)}`;} This step-through is a simple model. It is not a view of V8's real memory. Line 20 calls checkout. Line 16 calls total. Line 12 calls addTax. Each call gets its own frame. When a call returns, its frame is removed, and the line that was waiting goes on.
A frame has several parts, called slots. One slot, the context, points to where the function's outer variables are kept. A closure, a function that still uses variables from an outer function, needs it. In the table, each row names a slot, says what it is for, and says where it is kept.
| Slot | What it is for | Where it is kept |
|---|---|---|
| Return address | It says where the caller goes on after the call ends. | In the machine stack, as a place in the caller's code. |
| Link to the caller's frame | It points back to the caller's frame. Tools follow these links to list every call. | In the frame header, the fixed slots at the start of the frame. |
| Context | It points to the outer variables the function can use, like the ones a closure keeps. | The link is in the frame. The outer variables usually live in the heap. |
| Function details | It says which function is running, its bytecode, and how far it has got. | In the frame header and in V8's notes about the code. |
| Argument count | It says how many values the caller really passed. | In the frame of the called function. V8 has kept it there since it removed the arguments adaptor frame. |
Arguments and this | They are the values passed in, plus the object that becomes this. | Where the caller's frame meets the called function's frame, as the calling convention says. |
| Local values | They are the call's own variables and short-lived results. | In the interpreter's registers, or in spill slots in fast code. |
A frame doesn't hold big things like arrays. In total, the frame keeps the number sum. For items, it keeps only a link to the cart array. The array itself lives in the heap, the memory area for objects that can last longer than one call.
total keeps the number sum itself. For items it keeps only an arrow to the cart array. The array lives outside the frame, in the heap.On your phone, you open a contact card. The card shows a few details and links to more, like a saved address. While it is open, you might type a quick note. When you tap Back, you go to the screen you were on before. A frame works the same way.
- In real life: The contact card you have open now
- In JavaScript: The frame of the call that is running
- In real life: The card links to a saved address
- In JavaScript: The context slot points to outer variables
- In real life: A quick note you type while it is open
- In JavaScript: The local variables of this call
- In real life: The Back button takes you to the last screen
- In JavaScript: The return address goes back to the caller
How V8 starts a function call
A calling convention is a fixed plan for where each piece of a call goes. The plan covers:
- the object that becomes
this, called the receiver, - the arguments,
- the return address,
- a link to the caller's frame,
- a few fixed slots at the start of the frame, called the frame header.
V8 changes this plan between versions. What your JavaScript sees must stay the same.
The V8 article "Faster JavaScript calls" explains two changes. First, V8 now puts the arguments on the stack in reverse order. Second, it removed an extra helper frame, the arguments adaptor frame. That frame used to fix up calls with too few or too many arguments.
Now the frame of the called function keeps the real number of arguments. With that number, V8 can still build arguments and rest parameters, and it can clean up after the call.
V8 first turns each function into bytecode: short, simple instructions that it runs one at a time. The code below asks Node to print the bytecode for addTax. It works in Node only, not in the browser.
function addTax(amount) { const rate = 0.18; return amount + amount * rate;} addTax(150);The test runs this in Node 22, which has V8 12.4, with the flags --print-bytecode --print-bytecode-filter=addTax. It checks facts that stay the same on every run:
- V8 prints bytecode for
addTax. - The bytecode shows a parameter count.
- The math steps use
a0, which is the first argument,amount. - There is a
Returnstep.
It doesn't check memory addresses or exact positions, because those change.
Too few or too many arguments
In JavaScript you can pass fewer or more arguments than a function names. This is a rule of the language, not a V8 trick:
- A missing parameter gets the value
undefined. - An extra value has no name, but you can still read it. Every normal function has
arguments, a list-like object with every value passed in, in order.
Compare a call with too few values and a call with too many. The frame slots are a simple model; the printed results come from real JavaScript.
script
const passengerName = passenger ?? "Guest"; const destination = city ?? "Jaipur"; const extra = arguments[2] ?? "no extra"; return `${passengerName} -> ${destination}; extra: ${extra}; supplied: ${arguments.length}`;} console.log(makeBooking("Asha"));console.log(makeBooking("Ravi", "Kochi", "window seat"));undefined. An extra value has no name, but arguments[2] still holds it.function receiptLine(item, quantity) { return { declared: receiptLine.length, supplied: arguments.length, item, quantity, extras: Array.from(arguments).slice(2), };} receiptLine("chai", 2, "GST", "takeaway");2receiptLine.length counts the named parameters.
1arguments.length counts the values this call passed.
chaiThe first named parameter.
undefinedThe second named parameter.
return address -> back to the playground
argument count -> 1
item -> chai
quantity -> undefined
extras -> none
This call passed no extra values.
This call passed 1 value. The function names 2 parameters, so quantity is undefined. Extra values: none.
Now check the same facts with plain code. inspectCall.length is the number of parameters the function names. arguments.length is the number of values this call passed. The first call leaves second as undefined. The second call passes three values, so "window seat" is in arguments[2].
function inspectCall(first, second) { console.log("declared", inspectCall.length); console.log("supplied", arguments.length); console.log("first", first); console.log("second", second); console.log("extra", arguments[2]);} inspectCall("Asha");inspectCall("Ravi", "Kochi", "window seat");A rest parameter, written ...rest, collects the extra values into an array. A function's length stops counting at the rest parameter, so collect.length is 1.
function collect(first, ...rest) { console.log(collect.length); console.log(first); console.log(rest.join("|"));} collect("chai", "samosa", "jalebi");Simple frames and fast frames
V8 first runs your code with Ignition, its interpreter. An interpreter reads bytecode and does one step at a time. Its frames always have the same shape:
- a header with the context, the function, its bytecode, the current position in the bytecode, and the argument count,
- then a row of registers: small slots that hold the function's local values.
When a function runs many times, V8 may compile it with TurboFan, its optimizing compiler. TurboFan makes fast machine code. It chooses where each value goes. Some values stay inside the processor. Others are saved in frame slots called spill slots.
Fast code makes guesses about your values. If a guess turns out wrong, V8 switches back to the slow, safe version. This is called deoptimizing. To do it, TurboFan keeps notes on how to rebuild every JavaScript value. The same notes help V8 build stack traces.
V8 has several kinds of frames. Each row of the table says what one kind holds and why it exists.
| Kind of frame | What it holds | Why it exists |
|---|---|---|
| Ignition frame (the interpreter) | A header with the function, its bytecode, the position in it, and the argument count, then registers. | It starts fast and watches what values your code uses. |
| Sparkplug frame (the baseline compiler) | Machine code with a simple layout that still matches the bytecode closely. | It runs faster than the interpreter and is cheap to make. |
| TurboFan frame (the optimizing compiler) | Slots the compiler picks, plus notes for switching back to slow code. | It is the fastest code, but V8 must still be able to rebuild every JavaScript value. |
| Copied-in (inlined) frame | No frame of its own while the code runs. V8's notes remember the call. | V8 can rebuild it from the notes, so it still shows in stack traces. |
The JavaScript spec, the official rule book, says how calls, parameters, scopes, and return values behave. It doesn't say how a frame is laid out. V8's frame header, Ignition registers, and TurboFan spill slots are V8's own choices. Other engines can do it differently.
Copied-in functions still show in stack traces
Inlining means the optimizing compiler copies a small function's code into the function that calls it. Then the machine doesn't need a separate call, so the copied function may get no frame of its own.
V8 still keeps notes about the copy. When it builds a stack trace, it uses the notes to list the copied function as if it had been called.
The code below works in Node only. The lines that start with % are special V8 commands. They work only with the --allow-natives-syntax flag. Here they make V8 optimize checkout and report whether it is optimized.
function gstLine(amount) { return captureStack(amount * 1.18);} function captureStack(total) { return new Error("receipt " + total).stack;} function checkout(amount) { return gstLine(amount);} %PrepareFunctionForOptimization(gstLine);%PrepareFunctionForOptimization(captureStack);%PrepareFunctionForOptimization(checkout);for (let i = 0; i < 10000; i += 1) checkout(i);%OptimizeFunctionOnNextCall(checkout);const stack = checkout(100);const status = %GetOptimizationStatus(checkout);console.log("status", status);console.log("optimized", Boolean(status & (1 << 4)));console.log(stack.split("\n").filter((line) => /captureStack|gstLine|checkout/.test(line)).join("\n"));checkout has the gstLine code copied inside it, so there is no separate gstLine call. The stack trace still lists gstLine, because V8 keeps notes to rebuild it.The test runs this with --allow-natives-syntax --no-maglev --trace-turbo-inlining. It checks that:
- Node is version 22, with V8 12.4.
- V8 reports that
gstLinewas copied intocheckout. checkoutis optimized.- The stack text still names
captureStack,gstLine, andcheckout.
V8 also limits how many lines a stack trace shows. A stack trace is text made for debugging. It is not a copy of memory.
The code below makes three nested calls and counts the at lines in the trace. With Node's --stack-trace-limit=2 flag, it prints frames 2. A later lesson on the stack trace API goes further.
function leaf() { return new Error("limit").stack;}function middle() { return leaf();}function top() { return middle();} const frames = top().split("\n").filter((line) => line.trim().startsWith("at "));console.log("frames", frames.length);Using frames when you debug
You will use this most when you debug. When an error points at a helper function, read the top line of the stack trace first. Then go down to the functions that called it. At each step, check what was passed in:
- too few arguments,
- too many arguments,
- an object that some other code changed.
function formatReceipt(items, coupon) { const subtotal = items.reduce((sum, item) => sum + item.price, 0); const discount = coupon ? subtotal * coupon.percent : 0; return subtotal - discount;} console.log(formatReceipt([{ price: 500 }, { price: 250 }], { percent: 0.1 }));console.log(formatReceipt([{ price: 500 }, { price: 250 }], null));Line 2 adds up the prices and keeps the result in the local variable subtotal. Line 3 takes off a discount only if a coupon was passed. Line 7 passes a 10% coupon and prints 675. Line 8 passes null and prints 750.
- Read a stack trace as a list of your JavaScript calls, not as raw machine memory.
- Use
arguments.lengthor a rest parameter when a function really needs to know how many values were passed. - Remember that a local variable can point to an object in the heap, and that object can outlive the frame.
- Measure before you change code because of a guess about frames. Many arguments alone don't make code slow.
Common misconceptions
- "The stack trace is the stack." A stack trace is text that V8 builds from frames and its notes.
- "A frame holds every value its function uses." Objects live in the heap. A frame can point to them, and they can last longer than the frame.
- "
fn.lengthtells me how many arguments were passed." It counts the parameters the function names.arguments.lengthcounts the values passed in this call. - "Optimized code can't be debugged." V8 keeps notes so tools can rebuild the JavaScript values and calls.
- "V8's frame layout is part of JavaScript." The language rules are written in the spec. The frame layout is V8's own choice.
Some pairs of ideas are easy to mix up. Each row says what one idea means and what it is not.
| Idea | What it means | What it is not |
|---|---|---|
fn.length | How many parameters the function names, up to the first default value or rest parameter. | It is not how many values this call passed. |
arguments.length | How many values this call really passed. | It is not part of the function's code. It can change on every call. |
| A stack frame | The record for one running call, plus links and details. | It is not an object in the heap, and it is not a block of your code. |
| A copied-in (inlined) frame | A call that V8 rebuilds from its notes for a stack trace. | It may not have a real frame of its own in the machine stack. |
- The return address that lets
checkoutgo on aftertotal(cart). - The number in
sumwhiletotalis running. - The
cartarray and the item objects in it. - Outer variables that a closure still uses after the outer call has ended.
- The bytecode for
addTax. - Notes that let fast code rebuild copied-in calls for a stack trace.
Put each card where it mainly lives in this lesson's picture: in a stack frame, in the heap, or in V8's notes about your code.
Practice exercises
Type the three printed values in order.
function inspectCall(first, second) {
console.log(inspectCall.length);
console.log(arguments.length);
console.log(second);
}
inspectCall("Asha");The code prints 2, then 1, then undefined. The function names two parameters, the call passes one value, and the missing second parameter is undefined.
What value does the last console.log print?
function route(passenger, city) {
console.log(route.length);
console.log(arguments.length);
console.log(arguments[2]);
}
route("Ravi", "Kochi", "window seat");The extra argument is window seat. The function names two parameters, but the third value passed in is still in arguments[2].
On paper or in code comments, draw the frames at the moment total(cart) adds the second item in the checkout example.
Draw the script frame at the bottom. Above it, draw checkout, waiting at line 16. Above that, draw total, which is working out sum. Draw the cart array outside the frames, in the heap, with arrows from the frames to it.
Which frame slot says where the caller goes on after the call ends?
The slot is the return address. It tells the engine where the caller goes on after the called function returns.
The starter code tries to spot missing arguments. But percent = 0 gives percent a default value, and that makes the check wrong. Fix it so the call prints 1000.
function priceWithDiscount(price, percent = 0) {
if (arguments.length < priceWithDiscount.length) return "missing";
return price - price * percent;
}
console.log(priceWithDiscount(1000));function priceWithDiscount(price, percent = 0) {
if (arguments.length === 0) return "missing price";
return price - price * percent;
}
console.log(priceWithDiscount(1000));priceWithDiscount.length is 1, because counting stops at percent = 0. So comparing arguments.length < priceWithDiscount.length doesn't tell you whether percent is missing. Check the value you really need, here price, or use a rest parameter when you need the number of values passed.
Open a real project or a recent bug report. Next to each of the top three lines of a stack trace, write the argument or object that links it to the next one.
Pick a checkout, search, or form bug. Copy the stack trace and list its top three lines. Next to each one, write the argument or object it passed to the next function. A long trace becomes a short story of how the data moved.
Check your understanding
Question 1 of 7What is a stack frame?
Choose an answer to see the explanation.
Question 2 of 7What does this code print?
Read the code, then predictfunction inspect(first, second) { console.log(inspect.length); console.log(arguments.length); console.log(second); } inspect("Asha");Choose an answer to see the explanation.
Question 3 of 7V8 removed the arguments adaptor frame. What does the frame of the called function keep now?
Choose an answer to see the explanation.
Question 4 of 7What does this code print first?
Read the code, then predictfunction collect(first, ...rest) { console.log(collect.length); console.log(rest.length); } collect("chai", "samosa", "jalebi");Choose an answer to see the explanation.
Question 5 of 7Which thing usually lives in the heap, not in one frame?
Choose an answer to see the explanation.
Question 6 of 7V8 copied a small function into its caller. What can happen to it in a stack trace?
Choose an answer to see the explanation.
Question 7 of 7A stack trace surprises you. What is the best habit?
Choose an answer to see the explanation.
Key takeaways
- A stack frame is the record for one running call: where to go back to, the arguments, the local variables, and links.
- The machine stack, the JavaScript stack, the heap, and a stack trace are linked, but they are four different things.
- Ignition frames always have the same shape. TurboFan frames use slots the compiler picks, plus notes for going back and for debugging.
- A missing parameter is
undefined. An extra value is still inarguments. Modern V8 no longer needs an arguments adaptor frame. - A function copied into its caller can still show up in a V8 stack trace.
In one line: a function call is more than a jump. It makes a short-lived record that lets the engine go back, help you debug, and keep JavaScript's rules.
Next, Stack limits & stack overflow shows what happens when too many frames pile up, and how to rewrite risky recursion.