Back to the Lab

The Lab · Development

I Found the Exact Recursion Depth That Crashes Node

Plain recursion crashed at exactly 9,275 calls deep. An iterative rewrite handled 215 times that depth in 13ms.

RAXXO Studios 9 min read
TLDR This entry in one minute

Each line jumps to its section

  • Plain recursion crashed at exactly 9,275 calls deep on my default stack
  • An iterative rewrite walked a 2,000,000-node list in 13.37ms with zero crash risk
  • That's 215 times deeper than the point where recursion gave up
  • One while loop replaced a function that worked fine in testing and failed in production

Recursion crashed at exactly 9,275 calls deep on my machine, every single time I measured it. I wanted the real number instead of the usual advice to "just be careful with deep recursion," so I wrote a binary search that finds the exact crash point and ran it. Here's what that number means and when it actually matters.

Finding the Exact Number

The usual way people learn about stack overflows is by accident. A recursive function works fine against test data, ships, and then crashes three weeks later against a dataset nobody tested with. I wanted to know the actual boundary instead of waiting for it to find me.

So I wrote a function that walks a linked list recursively, one call per node, and built lists of different lengths until I found exactly where it broke:


function recursiveSum(node, depth) {
  if (!node) return { sum: 0, maxDepth: depth };
  return recursiveSum(node.next, depth + 1);
}

Rather than guessing at round numbers, I binary searched the crash point itself. Build a list of length N, try the recursive walk, and if it survives, go bigger; if it throws, go smaller. Fifteen or so iterations later I had the exact boundary instead of an estimate.

On this stack, that boundary was 9,275 calls. Not 9,000. Not "around 10,000." Exactly 9,275, and it repeated across multiple runs on the same process.

Same number, every run. No flakiness at all.

That precision matters less than the shape of the result. The number itself will differ on your machine, your runtime, and whether you're in Node or a browser tab. What won't differ is that the number exists, it's finite, and it's almost certainly smaller than you'd guess from how deep your test data went.

Node even lets you tune it with a --stack-size flag, which is a neat trick and a trap at the same time. Raise the limit and your function survives a little longer before hitting the same wall further out. It doesn't remove the wall. It just moves it somewhere you haven't tested yet.

I didn't touch that flag for this test on purpose. I wanted the default, because the default is what your code runs under in production unless you went out of your way to change it.

Why the Limit Exists at All

JavaScript engines, like most languages, give each thread a fixed amount of stack memory. Every function call pushes a new frame onto that stack: the arguments, the local variables, the return address. Call another function from inside that one and you push another frame on top. Return, and the frame pops off.

Recursion keeps pushing frames without popping any until it hits the base case. A loop doesn't. That's the entire difference, and it's also the entire reason the crash exists in the first place.

Nine thousand two hundred seventy five frames doesn't sound like much stack space being eaten per call, and it isn't. Each frame in this function is tiny, just a reference and a number. A recursive function with more local variables, more arguments, or a deeper call chain inside each step would hit its own wall far sooner, possibly in the hundreds instead of the thousands. I only tested this one shape. Yours might crash at 2,000 calls deep instead of 9,275, and you won't know which until you measure it the same way.

Tail-call optimization is supposed to fix this in theory, since a function whose last action is calling itself doesn't technically need to keep its own frame around. V8, the engine behind Node and Chrome, doesn't implement it. That's not a bug report, it's just the reality you're writing code against today, so "but TCO should handle this" isn't a plan.

It's in the spec. It's just not in the engine you're actually running. That gap between what the spec allows and what the engine does is exactly the kind of detail that only shows up when you test the real thing instead of reading about it.

The Iterative Rewrite, Timed

The fix is almost insultingly simple.

Swap the recursive call for a loop, and the call stack stops growing entirely because there's nothing left to push:


function iterativeSum(head) {
  let count = 0;
  let node = head;
  while (node) { count++; node = node.next; }
  return count;
}

I ran this against a list of 2,000,000 nodes, which is 215 times deeper than the point where the recursive version gave up. It finished in 13.37 milliseconds. No crash, no special handling, no try/catch wrapped around it hoping for the best.

That's the part worth sitting with. The recursive version wasn't slow before it crashed, it was fine right up until the exact moment it wasn't. There's no gradual slowdown warning you that the stack is getting full. It works, it works, it works, and then it throws a RangeError: Maximum call stack size exceeded on the one input that happened to be longer than whatever you tested against.

An iterative version trades that cliff for a flat, boring, predictable cost that scales with input size and nothing else. 13 milliseconds for two million nodes isn't a victory lap, it's just what a while loop does when you ask it to count things. No elegance required.

I also tried the halfway option, a manually managed stack where you push work onto an array instead of the call stack, so you keep the recursive style of thinking without actually recursing. It works, and it's a reasonable pattern for tree traversal where a plain loop gets awkward to write. For a straight linear walk like this one, it's extra code for no benefit over the while loop above. Save it for the cases where recursion's shape actually earns its keep, like walking a tree with branches instead of a flat list.

When I'd Actually Reach for This

Most recursive functions never go deep enough for any of this to matter. A function that recurses over a small, bounded tree, a handful of levels of nested settings, a short parse routine, none of that comes close to 9,275 frames. I'd leave all of that written recursively, because it reads better and the risk is close to zero.

The danger shows up specifically when the recursion depth is tied to the size of user-controlled or unpredictable input. A linked list built from database rows, a parser walking an uploaded file, a tree built from nested JSON that someone else generated. In all of those cases, the depth isn't something you chose, it's something your data decided for you, and your test fixture with twelve items tells you nothing about the file somebody uploads with four hundred thousand.

JSON.parse() itself is a good example of the category, since it's implemented natively and doesn't share this problem, but a hand-written recursive parser sitting on top of it, walking a deeply nested result to transform or validate it, absolutely does. Nothing stops nested configuration data or a generated export from going far deeper than whoever designed the schema expected. If your code walks that structure recursively to flatten it, validate it, or render it, the nesting depth of someone else's JSON is now your stack's problem.

A tree built from a comment thread with replies-to-replies is the same shape. Most threads are shallow. The one thread that goes fifteen thousand replies deep, because someone was testing the limits or a script generated it, is the one that finds your crash point for you, in front of users, at the worst possible time.

That's the actual lesson, more than the specific number. 9,275 is a fact about my machine today. The habit that matters is checking whether a function's recursion depth is bounded by something you control or something you don't, and rewriting the "don't" case before it ships, not after it crashes on exactly the input you didn't think to test.

There's a cheap way to check this before it becomes a production incident. Take whatever test fixture you've been using for a recursive function and multiply its size by a hundred. If you don't have a fixture that big lying around, generate one. Run the function against it once, on purpose, before a user does it for you by accident.

I'd go further for anything that touches a file upload, an API response of unknown size, or user-generated nesting: write the test with an absurdly large input on day one, not as an afterthought after the crash report comes in. It takes five minutes to generate a list of a million nodes. It takes a lot longer to figure out why production is throwing RangeError at 2am and nobody can reproduce it on a laptop with a small test file.

It's the same discipline behind running git bisect across a thousand real commits instead of guessing where a bug started, or measuring what content-visibility actually changed across 3,000 cards instead of assuming a CSS property does what the spec implies. Don't trust the shape of a function or a guess about performance. Trust what happens when you feed it something bigger than your test data. Guessing is fast. It's also how you end up debugging a production crash at the worst possible hour.

Bottom Line

Plain recursion on my stack broke at exactly 9,275 calls, every time I measured it, and an iterative rewrite of the same logic handled 215 times that depth in 13.37 milliseconds with nothing left to crash. The number 9,275 won't transfer to your machine or your function, so don't copy it down as a rule. What transfers is the method: if a function's recursion depth depends on something outside your control, like file size, row count, or nesting that a user generated, find its actual breaking point before a user does. A loop costs you nothing recursion didn't already cost you in readability, and it buys back the one failure mode you can't patch around after the fact.

Get the next entry by mail
One mail when a new entry lands. No spam. Unsubscribe anytime.
RAXXO Studios

Written by

RAXXO Studios

One designer in Berlin, close to twenty years in. I build tools with AI, use them daily, and write down what happened.

Share this entry

X LinkedIn
All entries