Credit: auksolution661454 / vecteezy.com

The JavaScript Call Stack

What happens when functions call other functions in JavaScript? How does JavaScript keep track of these function calls to know where it is?

To understand the JavaScript call stack, we need to understand the data structure underlying it — the stack.

The Stack

A Stack

A stack is a data structure that stores a linear collection of elements. Interacting with the stack occurs via two operations: push and pop.

  1. Push adds a new element to the top of the stack
  2. Pop removes the current element at the top of the stack

Limiting access to stack elements to the ‘push’ and ‘pop’ operations makes the stack a Last-In-First-Out (LIFO) data structure. Unlike an array, you do not modify or access any element in the stack except the top one. This ensures the order of elements in the stack remains stable, and you can only access them in reverse of the order they were added.

A stack will have a predefined size limit, after which trying to push an additional element on to the stack will result in a stack overflow error (hmm... sounds familiar).

A stack some of us might might recognize. Credit: supernatone / vecteezy.com

Some common examples of stacks in practice include your browsers back/forward buttons, and the undo/redo functions in your favorite text editor.

A stack can be easily implemented using a simple JavaScript Array and it’s push() and pop() methods.

So we know what a stack is… how does JavaScript use it to manage function calls?

The Call Stack

The call stack is a stack containing all nested function calls, allowing the JS interpreter to keep track of its position throughout execution. Every time a function is called it’s pushed onto the stack. After the function completes execution, it is popped off the stack. Here’s an example:

const foo = () => console.log("foo");const bar = () => foo();const baz = () => bar();

At this point, the only element in the stack is the ‘main’ function which represents the file itself.

Now let’s call baz:

baz();

Immediately, baz is pushed to the stack. It also calls a function, bar , which calls foo , which calls console.log. This is what our call stack looks like when those are invoked:

At this point the console.log is executed:

> foo

console.log , foo, bar, and baz are now finished executing, and are popped off the stack:

The call stack is empty, there are no more statements to run, and the program exits.

We can view the call stack by throwing an error in foo:

const foo = () => {  throw Error("foo");};const bar = () => foo();const baz = () => bar();baz();

Result:

throw Error("foo");
^
Error: foo
at foo (/test.js:2:9)
at bar
(/test.js:5:19)
at baz
(/test.js:7:19)
at Object.<anonymous> (/test.js:9:1)
...

We can see foo was called by bar which was called by baz. This is what our call stack looks like when the error is throw:

This depends on your browser, computer, and various other conditions: check it out yourself! e.g.

"Maximum stack size is 13947 in your current browser"

We can also see the error that is thrown when the limit is exceeded:

"RangeError: Maximum call stack size exceeded"

The call stack is synchronous, so what happens when an async function is called? The function call is handed off to the browser/Node API, and the calling function continues executing until it completes and is removed from the stack. The async function meanwhile goes into the message queue. The event loop will always prioritize functions currently in the call stack, and will only pick up from the message queue when the call stack is empty.

Read more in this explanation from Flavio Copes.

Summary

  • A stack is a linear data structure, allowing elements to be added or removed only from the top of the stack
  • The call stack in JavaScript keeps track of function calls, pushing to the stack when the function is invoked and being popped off the stack once it’s finished
  • The stack size limit is determined by many different factors, and exceeding this limit will result in a RangeError
  • The call stack executes code synchronously — async functions will wait in the message queue until the call stack is empty, at which point the event loop pushes them onto the call stack to run