JavaScript Recursion

Recursion is a technique where a function solves a problem by calling itself with a smaller version of the same problem, until it reaches a case simple enough to answer directly. It is one of the most powerful ideas in programming because many problems — walking a tree, searching nested data, computing mathematical sequences — are naturally defined in terms of smaller copies of themselves. In JavaScript, recursion is just a function calling itself, but understanding how the engine tracks each call is essential to using it safely.

Overview / How It Works

Every recursive function needs two parts: a base case that stops the recursion, and a recursive case that calls the function again with input that moves it closer to the base case. If you forget the base case, or if the recursive case never actually approaches it, the function will call itself forever (or until JavaScript refuses to let it continue).

Under the hood, every function call in JavaScript — recursive or not — pushes a new stack frame onto the call stack. That frame stores the function’s local variables, its parameters, and the address it should return to when it finishes. When a function calls itself, a brand new frame is pushed for that call, completely separate from the frame of the call that invoked it. Each recursive call therefore gets its own private copy of its parameters and local variables — they do not overwrite each other.

When the base case is finally reached, that innermost call returns a value. Execution then pops back to the frame that called it, which resumes exactly where it left off, using the returned value to finish its own computation, and so on back up the chain until the very first call returns to the caller. This is why recursive functions that build up a result (like a factorial) typically do their real work after the recursive call returns — on the way back up the stack.

The call stack has a finite size. Each active call consumes memory for its frame, and if recursion goes too deep — usually many thousands of calls, depending on the engine and available memory — JavaScript throws a RangeError: Maximum call stack size exceeded. This is called a stack overflow, and it is the most common real-world bug in recursive code.

Syntax

There is no special keyword for recursion — it is simply a named function (or a function expression assigned to a variable) that refers to itself inside its own body:

function functionName(parameters) {
  if (baseCaseCondition) {
    return baseCaseValue;
  }
  return functionName(smallerParameters);
}
  • Base case — a condition checked first that returns a value directly, without calling the function again. Every recursive function must have at least one.
  • Recursive case — the branch where the function calls itself, passing arguments that are "smaller" or closer to the base case (a decremented number, a shorter array, a shallower object).
  • Return value — recursive functions almost always use return so that each call passes its result back up to the call that invoked it.

Arrow functions can recurse too, but they need a name to refer to, so they are usually assigned to a const and call that variable name:

const factorial = (n) => (n <= 1 ? 1 : n * factorial(n - 1));

Examples

Example 1: Counting down

A simple recursive function that prints numbers from n down to 1, then a final message. This is the clearest way to see the base case and recursive case working together.

function countdown(n) {
  if (n <= 0) {
    console.log("Liftoff!");
    return;
  }
  console.log(n);
  countdown(n - 1);
}

countdown(3);

Output:

3
2
1
Liftoff!

Each call checks whether n has reached the base case (n <= 0). If not, it logs n and calls itself with n - 1. Because the argument shrinks by 1 every time, the base case is guaranteed to be reached eventually.

Example 2: Factorial

The factorial of n (written n!) is the product of all positive integers up to n. It is a textbook recursion example because n! = n × (n - 1)!, which is exactly a smaller version of the same problem.

function factorial(n) {
  if (n <= 1) {
    return 1;
  }
  return n * factorial(n - 1);
}

console.log(factorial(5));
console.log(factorial(0));

Output:

120
1

Here the real multiplication happens after the recursive call returns: factorial(5) waits for factorial(4) to finish, which waits for factorial(3), and so on, until factorial(1) returns 1 directly. Then each waiting call multiplies its own n by the returned value as the stack unwinds: 1 → 2 → 6 → 24 → 120.

Example 3: Summing a nested array

Recursion shines with data that can be nested arbitrarily deep, like arrays inside arrays. A loop alone cannot handle unknown nesting depth, but recursion handles it naturally — whenever an item is itself an array, recurse into it.

function sumNested(arr) {
  let total = 0;
  for (const item of arr) {
    if (Array.isArray(item)) {
      total += sumNested(item);
    } else {
      total += item;
    }
  }
  return total;
}

console.log(sumNested([1, [2, 3], [4, [5, 6]], 7]));

Output:

28

Every time sumNested encounters a nested array, it calls itself on that smaller array and adds the result to its running total. The recursion bottoms out on plain numbers, which just get added directly — that implicit case (a non-array item) acts as the base case here.

Under the Hood: Tracing the Call Stack

To really understand recursion, trace what the engine does for factorial(3) step by step:

  • Call factorial(3). 3 <= 1 is false, so it needs factorial(2). A new stack frame for factorial(3) stays open, waiting.
  • Call factorial(2). Again 2 <= 1 is false, so it needs factorial(1). This frame also stays open.
  • Call factorial(1). Now 1 <= 1 is true — the base case — so it returns 1 immediately without any further calls.
  • The factorial(2) frame resumes: it computes 2 * 1 = 2 and returns 2.
  • The factorial(3) frame resumes: it computes 3 * 2 = 6 and returns 6.

At the deepest point, three frames existed on the call stack simultaneously (factorial(3), factorial(2), factorial(1)). This is why deep recursion costs memory proportional to the depth of the calls, not just time.

Some recursive algorithms recompute the same subproblem many times, which wastes both time and stack depth. A classic case is the naive Fibonacci recursion, where fib(n) calls fib(n - 1) and fib(n - 2), and those calls overlap heavily. The fix is memoization — caching results that have already been computed:

function fibMemo(n, memo = {}) {
  if (n in memo) {
    return memo[n];
  }
  if (n <= 1) {
    return n;
  }
  memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
  return memo[n];
}

console.log(fibMemo(10));
console.log(fibMemo(30));

Output:

55
832040

Without memoization, fibMemo(30) would trigger over a million redundant calls; with the cache object memo, each value from 0 to 30 is computed only once, turning an exponential-time algorithm into a linear-time one.

Common Mistakes

Mistake 1: Missing or unreachable base case. If a function has no condition that stops the recursion, or the condition can never become true, every call keeps spawning another call until the stack overflows.

function countDownBroken(n) {
  console.log(n);
  countDownBroken(n - 1); // never checks a stopping condition
}
// countDownBroken(5) runs until RangeError: Maximum call stack size exceeded

The fix is to always check a stopping condition before recursing, and make sure the argument actually moves toward it:

function countDownFixed(n) {
  if (n <= 0) return;
  console.log(n);
  countDownFixed(n - 1);
}

Mistake 2: Forgetting to return the recursive call's result. If you call the function recursively but don't do anything with what it returns, the computed value is silently discarded.

function factorialBroken(n) {
  if (n <= 1) return 1;
  factorialBroken(n - 1); // result is thrown away!
}
console.log(factorialBroken(5)); // undefined, not 120

The fix is to use return so the value flows back up the call chain, as shown in the corrected factorial function earlier in this lesson.

Mistake 3: Redoing expensive work with no cache. Naive recursive solutions to problems like Fibonacci or combinatorics can recompute the same subproblem exponentially many times. If a recursive function feels slow for even moderate input sizes, look for repeated subproblems and consider memoization, as shown with fibMemo above.

Best Practices

  • Always write the base case first and test that it triggers correctly before worrying about the recursive case.
  • Make sure every recursive call passes arguments that move strictly closer to the base case (smaller number, shorter array, shallower object).
  • Use return consistently so each call's result flows back to its caller.
  • For problems with overlapping subproblems (like Fibonacci), add memoization with a plain object or a Map to avoid exponential blowups.
  • Prefer an iterative loop over recursion for simple linear counting or accumulation when performance and stack depth matter — use recursion where it makes the code genuinely clearer, such as tree/graph traversal or nested data structures.
  • Watch input size: if a recursive function could realistically be called on very deep or very large input, verify it won't exceed the call stack, or convert it to an iterative equivalent.

Practice Exercises

  • Write a recursive function power(base, exponent) that computes base raised to exponent without using ** or Math.pow. For example, power(2, 5) should return 32.
  • Write a recursive function reverseString(str) that returns a string reversed, for example reverseString("hello") should return "olleh". Hint: the base case is a string of length 0 or 1.
  • Write a recursive function countDeep(arr) that counts how many total numbers exist in a possibly nested array, for example countDeep([1, [2, [3, 4]], 5]) should return 5.

Summary

  • Recursion is when a function calls itself to solve smaller instances of the same problem.
  • Every recursive function needs a base case (to stop) and a recursive case (to progress toward the base case).
  • Each call gets its own stack frame with its own copies of parameters and local variables; frames stay open until the base case unwinds them one by one.
  • Missing or unreachable base cases cause a RangeError: Maximum call stack size exceeded.
  • Forgetting to return a recursive call's result silently discards the computed value.
  • Memoization avoids redundant recomputation in recursive algorithms with overlapping subproblems, like Fibonacci.
  • Recursion is especially natural for nested or tree-shaped data; simple linear counting is often clearer and cheaper as an iterative loop.