JavaScriptMedium

How does recursion work in JavaScript?

01

The Short Answer

Recursion is when a function calls itself to solve a problem by breaking it into smaller, identical subproblems. Every recursive function needs two things: a base case (the condition that stops the recursion) and a recursive case (where the function calls itself with a smaller input). Without a base case, the function calls itself infinitely until the call stack overflows. Recursion is natural for problems with self-similar structure — trees, nested objects, mathematical sequences, and divide-and-conquer algorithms.

02

Anatomy of a Recursive Function

Every recursive function follows the same pattern: check if you've hit the base case (return immediately if so), otherwise transform the problem into a smaller version and call yourself. The call stack keeps track of each invocation — when the base case is finally reached, the stack unwinds and each call returns its result back up the chain.

recursion-basics.tstypescript
// Classic example: factorial
// 5! = 5 × 4 × 3 × 2 × 1 = 120
function factorial(n: number): number {
  // Base case — stops the recursion
  if (n <= 1) return 1;

  // Recursive case — calls itself with smaller input
  return n * factorial(n - 1);
}

// How the call stack builds up:
// factorial(5)
//   → 5 * factorial(4)
//     → 4 * factorial(3)
//       → 3 * factorial(2)
//         → 2 * factorial(1)
//           → 1 (base case hit!)
//         → 2 * 1 = 2
//       → 3 * 2 = 6
//     → 4 * 6 = 24
//   → 5 * 24 = 120

// Each call waits for the next one to return before it can compute its result
// The stack grows to depth n before unwinding
03

Practical Examples

Traversing Nested Structures

Recursion shines when dealing with nested data of unknown depth — like a file system, a comment thread, or a deeply nested object. You can't write a fixed number of loops because you don't know how deep the nesting goes. Recursion handles this naturally: process the current level, then recurse into each child.

nested-structures.tstypescript
// Deep flatten an arbitrarily nested array
function deepFlatten<T>(arr: (T | T[])[]): T[] {
  const result: T[] = [];

  for (const item of arr) {
    if (Array.isArray(item)) {
      result.push(...deepFlatten(item)); // Recurse into nested arrays
    } else {
      result.push(item); // Base case: not an array
    }
  }

  return result;
}

deepFlatten([1, [2, [3, [4, 5]]], 6]); // [1, 2, 3, 4, 5, 6]

// Sum all values in a nested object (any depth)
function deepSum(obj: Record<string, unknown>): number {
  let total = 0;

  for (const value of Object.values(obj)) {
    if (typeof value === 'number') {
      total += value; // Base case: it's a number
    } else if (typeof value === 'object' && value !== null) {
      total += deepSum(value as Record<string, unknown>); // Recurse
    }
  }

  return total;
}

deepSum({ a: 1, b: { c: 2, d: { e: 3 } }, f: 4 }); // 10

// Find a node in a tree
type TreeNode = { id: string; children: TreeNode[] };

function findNode(tree: TreeNode, targetId: string): TreeNode | null {
  if (tree.id === targetId) return tree; // Base case: found it

  for (const child of tree.children) {
    const found = findNode(child, targetId); // Recurse into children
    if (found) return found;
  }

  return null; // Not in this subtree
}

Divide and Conquer

Many efficient algorithms use recursion to split a problem in half repeatedly. Merge sort, quicksort, and binary search all follow this pattern: divide the input, solve each half recursively, then combine the results. The recursive structure naturally maps to the algorithm's logic.

divide-and-conquer.tstypescript
// Binary search — O(log n)
function binarySearch(arr: number[], target: number, low = 0, high = arr.length - 1): number {
  if (low > high) return -1; // Base case: not found

  const mid = Math.floor((low + high) / 2);

  if (arr[mid] === target) return mid;           // Base case: found
  if (arr[mid] < target) return binarySearch(arr, target, mid + 1, high); // Right half
  return binarySearch(arr, target, low, mid - 1); // Left half
}

// Merge sort — O(n log n)
function mergeSort(arr: number[]): number[] {
  if (arr.length <= 1) return arr; // Base case

  const mid = Math.floor(arr.length / 2);
  const left = mergeSort(arr.slice(0, mid));   // Sort left half
  const right = mergeSort(arr.slice(mid));     // Sort right half

  return merge(left, right); // Combine sorted halves
}

function merge(left: number[], right: number[]): number[] {
  const result: number[] = [];
  let leftIdx = 0;
  let rightIdx = 0;

  while (leftIdx < left.length && rightIdx < right.length) {
    if (left[leftIdx] <= right[rightIdx]) {
      result.push(left[leftIdx++]);
    } else {
      result.push(right[rightIdx++]);
    }
  }

  return [...result, ...left.slice(leftIdx), ...right.slice(rightIdx)];
}
04

The Call Stack and Stack Overflow

Each recursive call adds a frame to the JavaScript call stack. The stack has a limited size (varies by engine — roughly 10,000-25,000 frames in V8). If your recursion goes too deep without hitting a base case, you get a RangeError: Maximum call stack size exceeded. This is the main limitation of recursion in JavaScript — unlike some languages, JS doesn't optimize tail calls in practice (despite it being in the ES2015 spec).

stack-overflow.tstypescript
// ❌ Stack overflow — no base case
function infinite(): number {
  return infinite(); // Never stops!
  // RangeError: Maximum call stack size exceeded
}

// ❌ Stack overflow — input too large for recursive approach
function sumTo(n: number): number {
  if (n <= 0) return 0;
  return n + sumTo(n - 1);
}
sumTo(100000); // Stack overflow! 100,000 frames deep

// ✅ Convert to iteration for large inputs
function sumToIterative(n: number): number {
  let total = 0;
  for (let i = 1; i <= n; i++) {
    total += i;
  }
  return total;
}
sumToIterative(100000); // Works fine — no stack growth

// ✅ Tail-call form (optimized in Safari, not V8/SpiderMonkey)
function sumToTail(n: number, accumulator = 0): number {
  if (n <= 0) return accumulator;
  return sumToTail(n - 1, accumulator + n); // Tail position
  // In theory, engine can reuse the same stack frame
  // In practice, only Safari implements TCO
}
05

Recursion vs Iteration

Every recursive solution can be rewritten iteratively (usually with an explicit stack). The choice depends on clarity and constraints. Recursion is more readable for tree/graph traversal and divide-and-conquer. Iteration is safer for large inputs (no stack overflow) and often faster (no function call overhead). In interviews, know both approaches and be ready to convert between them.

AspectRecursionIteration
ReadabilityMore natural for trees, nested structuresMore natural for linear sequences
MemoryO(n) stack space per depth levelO(1) for simple loops
Stack overflow riskYes — limited call stack depthNo — loop doesn't grow the stack
PerformanceFunction call overhead per frameSlightly faster (no call overhead)
When to useTrees, graphs, divide-and-conquer, unknown depthLarge inputs, performance-critical paths
06

Memoization — Optimizing Recursive Solutions

Naive recursion can be extremely slow when the same subproblem is solved multiple times. The classic example is Fibonacci — fib(5) calls fib(3) twice, fib(2) three times, etc. Memoization caches results of previous calls so each subproblem is solved only once, turning exponential time complexity into linear.

memoization.tstypescript
// ❌ Naive Fibonacci — O(2^n) time, recalculates everything
function fibNaive(n: number): number {
  if (n <= 1) return n;
  return fibNaive(n - 1) + fibNaive(n - 2);
}
// fibNaive(40) takes several seconds!
// fibNaive(50) takes minutes!

// ✅ Memoized Fibonacci — O(n) time, each value computed once
function fibMemo(n: number, cache: Map<number, number> = new Map()): number {
  if (n <= 1) return n;
  if (cache.has(n)) return cache.get(n)!; // Already computed

  const result = fibMemo(n - 1, cache) + fibMemo(n - 2, cache);
  cache.set(n, result); // Store for future calls
  return result;
}
// fibMemo(50) returns instantly

// Generic memoize wrapper
function memoize<Args extends unknown[], Result>(
  fn: (...args: Args) => Result
): (...args: Args) => Result {
  const cache = new Map<string, Result>();

  return (...args: Args): Result => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key)!;

    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

const fib = memoize((n: number): number => {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
});
07

Why Interviewers Ask This

Recursion is fundamental to computer science and comes up constantly in coding interviews. Interviewers want to see that you can identify when recursion is the right tool (nested/tree structures, divide-and-conquer), always define a clear base case, understand the call stack and stack overflow risks, know how to optimize with memoization, and can convert between recursive and iterative solutions. It tests both your problem-solving approach and your understanding of how JavaScript executes code.

Quick Revision Cheat Sheet

Two requirements: Base case (stops recursion) + recursive case (calls itself with smaller input)

Call stack: Each call adds a frame — limited to ~10,000-25,000 in V8

Stack overflow: RangeError when recursion is too deep — convert to iteration for large inputs

Best for: Trees, nested objects, divide-and-conquer, unknown depth

Memoization: Cache results to avoid recomputing — turns O(2^n) into O(n)

Tail call optimization: In ES2015 spec but only Safari implements it — don't rely on it