What is the Call Stack in Javascript? (And how does it get ...exceeded?)

📅 March 16, 2021

👷 Chris Power

Have you ever seen this error?

RangeError: Maximum call stack size exceeded

If you’re like me, then this particular error has probably kept you up at night, wondering what you did to deserve such horror in this world. It has shaken you to your core, brought you to your knees and made you question your very existence. Or it hasn’t bothered you much at all, in which case, you’re probably a sociopath.

I recently ran into a stack overflow error at my frontend job, and I want to talk about how I solved it!

Overview

Recently at work, I ran into a bug that caused a maximum call stack exceeded error in javascript. In this post, I will break down:

  • What the Javascript call stack is
  • How the call stack can be exceeded
  • How a bug at work led to the call stack being exceeded
  • And how I fixed the bug at work!

I will also recap this whole experience in my conclusion. And why my solution is actually just hiding a deeper problem in our codebase. Alright, let’s get into it! the CALL STACK EXCEEDED ERROR, ooohhhhhhhhh

The Call Stack

The most straightforward definition of the call stack goes something like this: “Its a way for javascript to keep track of the functions that are called and/or trying to be called”

https://www.javascripttutorial.net/javascript-call-stack/

When a javascript program gets run, javascript creates a global stack to keep track of functions and their execution.

You can think of the stack like a fancy array. The stack operates in a LIFO (last in first out) manner. When javascript tries to execute functions, the javascript engine will push the functions onto the stack, then iteratively pop them off the stack and execute them one by one. When the stack is empty, your program is finished!

Let’s take a look at an example of the stack in action.

function add(a, b) {
    return a + b;
}

function average(a, b) {
    return add(a, b) / 2;
}

average(5, 10);

In the above example. when average is called, it gets pushed onto the bottom of the stack. Then average calls add. And add gets pushed onto the stack. Add gets executed first, then average, then you’re done!

call stack diagram

The Problem I faced (at work)

My current job deals with a lot of polygon mapping, and [x, y] coordinates. in my job, we allow users to select polygons, each containing massive amount of points. With these points, we call Math.min and Math.max to find the edges of the viewport to zoom to (in the map). Each polygon could potentially contain thousands of points.

When a user selected a bunch of polygons with a ton of points, we were hitting a Maximum call stack exceeded error! Knowing how the call stack works, it’s easy to see that we were pushing too many functions onto the call stack before they could be executed. The call stack filled up with functions, and eventually overflowed and could not continue.

The app would then crash, and users would have no idea why.

But …Why?

When looking at the V8 (the javascript engine in chrome) codebase, we can find the math min and max functions.

function MathMax(arg1, arg2) {  // length == 2
  var length = %_ArgumentsLength();
  if (length == 2) {
    arg1 = TO_NUMBER(arg1);
    arg2 = TO_NUMBER(arg2);
    if (arg2 > arg1) return arg2;
    if (arg1 > arg2) return arg1;
    if (arg1 == arg2) {
      // Make sure -0 is considered less than +0.
      return (arg1 === 0 && %_IsMinusZero(arg1)) ? arg2 : arg1;
    }
    // All comparisons failed, one of the arguments must be NaN.
    return NaN;
  }
  var r = -INFINITY;
  for (var i = 0; i < length; i++) {
    var n = %_Arguments(i);
    n = TO_NUMBER(n);
    // Make sure +0 is considered greater than -0.
    if (NUMBER_IS_NAN(n) || n > r || (r === 0 && n === 0 && %_IsMinusZero(r))) {
      r = n;
    }
  }
  return r;
}

As you can see above 👆, Math.max appears to call the MathMax function for every point it wants to compare. This means that our Math.max and Math.min functions are actually recursive functions. This means that for every point in our polygons, we are adding a new function to the call stack! When we have enough points, we overflow the stack and run into the error.

But, how many points does it take to overflow the stack? As it turns out, we can easily test this on our machine. We simply have to run a function that recurses indefinitely, increase a count for every function call, and console out the count when we catch an error. example code below 👇.

function callStackTest() {
    let stackCount = 0;
    function recursiveExplosion() {
        stackCount += 1;
        recursiveExplosion();
    }
    try {
        recursiveExplosion();
    } catch (e) {
        console.log(stackCount);
        console.log(e);
    }
}

callStackTest();

Looks like at about 15k function calls we overload the stack. This is a very simple function, and I’m sure the Math functions take up more memory per function. So we can safely guess that after 9-10k function calls (or points) we were hitting a stack overflow.

Our machine is also a brand new M1 Macbook pro. Most users do not have such powerful machines, and could potentially overflow their stack much quicker.

The Solution

ITERATION! the easiest solution was to use a simple for loop. Loop over every point in the array, and sort them based on their size. Then you can find the min and max of our points to determine the size of the viewport.

function getMax(arr: number[]) {
  let len = arr.length;
  let max = -Number.MAX_VALUE;
  while (len--) {
    max = arr[len] > max ? arr[len] : max;
  }
  return max;
}
function getMin(arr: number[]) {
  let len = arr.length;
  let min = Number.MAX_VALUE;
  while (len--) {
    min = arr[len] < min ? arr[len] : min;
  }
  return min;
}

In the above example. We create a couple functions that loop over an array of items. We then sort those items and find the min/max values respectively. This side-steps our need for recursion. Because we are not calling a new function for every point in the loop, we don’t hit a stack overflow error. This may take some time to compute when given a lot of points, BUT we don’t hit any errors.

Conclusion

Our solution was fairly clever. We sidestep the stack overflow error by using a loop with a simple sorting algorithm to find the min and max points to set our viewport. There is; however, a bit of a problem with this approach.

Looping over a large amount of points could take some time. A user’s machine would have to bear the burden of computing the min/max of potentially thousands of points. I think this is something of an anti pattern, because most users don’t have extremely powerful machines, and you shouldn’t rely on a client to compute large amounts of data.

Essentially, the stack overflow error was probably a good thing. It showed the users and the developers that there was too much computation happening on the frontend. The error, in fact, protected the user’s computer from having to compute TOO MUCH.

In the end, the better solution would be to push this information to a server somewhere to compute this stuff, and bring the solution back to the frontend with a network call. Either way, this was a fun journey into the land of javascript function execution and I hope you learned as much as I did!

Lets Work Together

We're trusted by large, medium, and small companies all over the world

Have something you're working on?

Tell Us About It

Like what you read?

Check out some other posts