There is one way of solving problems with functions you have not used on purpose yet, and it is the most disconcerting of all: a function that calls itself. You have already run into it twice —the named function expression in 03-02 existed partly for this, and the RangeError: Maximum call stack size exceeded in 03-05 turned up because of a call that never stopped. In this lesson you will approach it methodically. And it is not an academic exercise: Nómada Tasks has a problem that loops solve badly and recursion solves naturally, because Marta wants a task to be breakable into subtasks, and those subtasks into others, with no depth limit.

Contents

  1. What a recursive function is
  2. The base case and the recursive case
  3. The call stack of a recursion
  4. Warm-up 1: factorial
  5. Warm-up 2: Fibonacci and its hidden cost
  6. The real case: nested subtasks
  7. Walking the tree: adding up hours
  8. Walking the tree: flattening, counting and searching
  9. Recursion versus iteration
  10. Tail recursion and why JavaScript does not optimize it
  11. Memoization applied to Fibonacci
  12. When NOT to use recursion
  13. Common Mistakes and Tips
  14. Exercises
  15. Conclusion

  1. What a recursive function is

A recursive function is one that, inside its body, calls itself with a smaller problem, until it reaches a case simple enough to be solved directly.

function countdown(n) {
  if (n <= 0) {                      // base case: solved without recursion
    console.log('Done!');
    return;
  }
  console.log(`${n} tasks left to review…`);
  countdown(n - 1);                  // recursive case: the same problem, smaller
}

countdown(3);
// 3 tasks left to review…
// 2 tasks left to review…
// 1 tasks left to review…
// Done!

The idea behind it is a way of thinking, not a syntax trick:

To solve a big problem, assume you already know how to solve the same problem slightly smaller, and combine that result with whatever you have to do here.

This is called the recursive leap of faith, and it is the hardest part. When you write countdown(n - 1) you do not have to picture all the chained calls: you only have to trust that that call does its job properly.

  1. The base case and the recursive case

Every correct recursive function has exactly two parts:

Part What it does What happens if it is missing or wrong
Base case Solves the smallest problem without calling itself again Infinite recursion → RangeError
Recursive case Calls itself with a smaller problem The function never progresses or solves nothing

And two conditions that must always be verified:

  1. The base case is reachable. It is not enough for it to exist: the argument must get closer to it on every call.
  2. The problem really does shrink. Calling yourself with the same argument, or a larger one, is an infinite loop with extra steps.
// ✗ Base case unreachable with decimals
function badCount(n) {
  if (n === 0) return 'end';
  return badCount(n - 1);
}
// badCount(3.5) → 2.5, 1.5, 0.5, -0.5, -1.5… never exactly 0

// ✓ A robust base case
function goodCount(n) {
  if (n <= 0) return 'end';
  return goodCount(n - 1);
}
flowchart TD
    A["Call with n"] --> B{"Base case?"}
    B -->|yes| C["Return the direct result"]
    B -->|no| D["Do the part that belongs here"]
    D --> E["Call itself with a smaller problem"]
    E --> F["Combine the result and return"]

  1. The call stack of a recursion

Here we pick the stack back up from Hoisting and the Execution Context. Each recursive call pushes a new context that is not released until the inner call finishes.

function sumUpTo(n) {
  if (n <= 0) return 0;
  return n + sumUpTo(n - 1);
}

console.log(sumUpTo(4));   // 10
sequenceDiagram
    participant G as global
    participant A as sumUpTo(4)
    participant B as sumUpTo(3)
    participant C as sumUpTo(2)
    participant D as sumUpTo(1)
    participant E as sumUpTo(0)

    G->>A: call
    A->>B: 4 + ?
    B->>C: 3 + ?
    C->>D: 2 + ?
    D->>E: 1 + ?
    E-->>D: 0 (base case)
    D-->>C: 1 + 0 = 1
    C-->>B: 2 + 1 = 3
    B-->>A: 3 + 3 = 6
    A-->>G: 4 + 6 = 10

Two essential observations:

  • The "way down" phase stacks calls without computing anything definitive: each one waits for the result of the next.
  • The "way up" phase is where the calculations actually happen, from the inside out.

That is why a recursion 50,000 levels deep blows the stack, as you saw in the final exercise of 03-05: all 50,000 additions are pending at the same time.

  1. Warm-up 1: factorial

The factorial of n is the product of every integer from 1 to n. Its mathematical definition is already recursive: n! = n × (n-1)!, with 0! = 1.

function factorial(n) {
  if (n < 0) throw new RangeError('Factorial is not defined for negative numbers.');
  if (n <= 1) return 1;              // base case: 0! = 1 and 1! = 1
  return n * factorial(n - 1);       // recursive case
}

console.log(factorial(0));   // 1
console.log(factorial(5));   // 120
console.log(factorial(6));   // 720

A trace of factorial(5):

Call Returns Result
factorial(5) 5 * factorial(4) 5 * 24 = 120
factorial(4) 4 * factorial(3) 4 * 6 = 24
factorial(3) 3 * factorial(2) 3 * 2 = 6
factorial(2) 2 * factorial(1) 2 * 1 = 2
factorial(1) 1 (base case) 1

Notice the throw for the negative case: it is the fail-fast approach from Error Handling. Without it, factorial(-1) would head off toward -Infinity stacking contexts and end up in a RangeError, an error that explains nothing.

  1. Warm-up 2: Fibonacci and its hidden cost

The Fibonacci sequence starts 0, 1, 1, 2, 3, 5, 8, 13…, where each term is the sum of the two before it.

function fibonacci(n) {
  if (n < 0) throw new RangeError('n must be 0 or greater.');
  if (n === 0) return 0;             // base case 1
  if (n === 1) return 1;             // base case 2
  return fibonacci(n - 1) + fibonacci(n - 2);   // TWO recursive calls
}

console.log(fibonacci(10));   // 55

Elegant and correct. And disastrous in performance, because each call spawns two, and many of them repeat work already done:

flowchart TD
    A["fib(5)"] --> B["fib(4)"]
    A --> C["fib(3) ①"]
    B --> D["fib(3) ②"]
    B --> E["fib(2) ①"]
    D --> F["fib(2) ②"]
    D --> G["fib(1)"]
    C --> H["fib(2) ③"]
    C --> I["fib(1)"]

fib(3) is computed twice and fib(2) three times, and with larger n the duplication explodes. The number of calls grows exponentially:

let calls = 0;
function countedFibonacci(n) {
  calls++;
  if (n <= 1) return n;
  return countedFibonacci(n - 1) + countedFibonacci(n - 2);
}

calls = 0; countedFibonacci(10); console.log(calls);   // 177
calls = 0; countedFibonacci(20); console.log(calls);   // 21891
calls = 0; countedFibonacci(30); console.log(calls);   // 2692537
calls = 0; countedFibonacci(35); console.log(calls);   // 29860703

From 10 to 35, the calls go from 177 to nearly 30 million. In section 11 you will fix this with memoization, and the improvement will be spectacular. Keep in mind that fibonacci(35) takes several seconds: you will compare it.

  1. The real case: nested subtasks

Up to now Nómada Tasks has worked with a flat list. But Marta has asked for something reasonable: that a big task can be broken down into subtasks, and that those subtasks can be broken down in turn. Here is task 1 of the backlog broken out:

flowchart TD
    A["1 · Redesign the multipurpose room"] --> B["11 · Measure and draw up the floor plan<br/>3 h"]
    A --> C["12 · Choose the furniture"]
    A --> D["13 · Paint and assemble<br/>5 h"]
    C --> E["121 · Request quotes<br/>2 h"]
    C --> F["122 · Visit two suppliers<br/>2 h"]

That structure is a tree, and in JavaScript it is represented with objects containing arrays of objects:

'use strict';

// Note: here there is no way around using nested objects. Objects are covered
// in depth in Module 4; for now, reading properties with a dot is enough.
const redesignTask = {
  id: 1,
  title: 'Redesign the multipurpose room',
  assignee: 'Iván',
  status: 'in-progress',
  estimatedHours: 0,          // 0 = the hours live in the subtasks
  subtasks: [
    {
      id: 11, title: 'Measure and draw up the floor plan', assignee: 'Iván',
      status: 'done', estimatedHours: 3, subtasks: []
    },
    {
      id: 12, title: 'Choose the furniture', assignee: 'Marta',
      status: 'in-progress', estimatedHours: 0,
      subtasks: [
        { id: 121, title: 'Request quotes', assignee: 'Marta',
          status: 'done', estimatedHours: 2, subtasks: [] },
        { id: 122, title: 'Visit two suppliers', assignee: 'Marta',
          status: 'pending', estimatedHours: 2, subtasks: [] }
      ]
    },
    {
      id: 13, title: 'Paint and assemble', assignee: 'Iván',
      status: 'pending', estimatedHours: 5, subtasks: []
    }
  ]
};

Why is a loop not enough here? Because you do not know how many levels there are. With one for you walk the first level; with two nested, the second; with three, the third. But if tomorrow Iván adds a sub-sub-subtask, the code stops working. Recursion, on the other hand, does not need to know: every level is treated exactly like the one before it.

This is the sign that gives a recursive problem away: the structure of the data contains itself. A task contains tasks, a folder contains folders, a comment contains replies that are comments.

  1. Walking the tree: adding up hours

The first calculation Marta needs: how many hours a task adds up to counting all its subtasks, at any depth.

/**
 * Adds up the hours of a task and of all its subtasks, recursively.
 * @param {Object} task  a node with estimatedHours and subtasks
 * @returns {number} the subtree's total hours
 */
function sumTotalHours(task) {
  let total = task.estimatedHours;                  // what this node contributes

  for (const subtask of task.subtasks) {            // recursive case
    total += sumTotalHours(subtask);
  }

  return total;                                     // implicit base case: with no subtasks the loop never runs
}

console.log(sumTotalHours(redesignTask));   // 12

It is worth analyzing why this works:

  • The base case is implicit. If subtasks is [], the for makes no passes at all and the function returns only task.estimatedHours. No explicit if is needed, although writing one would be fine too.
  • Every node does the same thing: it contributes its own hours and asks its children to contribute theirs.
  • The result, 12 h, matches the hours task 1 had in the flat backlog of Module 2 (3 + 2 + 2 + 5 = 12). Breaking it down did not change the total.

And a variant that filters: the subtree's open (unfinished) hours.

function sumOpenHours(task) {
  let total = task.status !== 'done' ? task.estimatedHours : 0;

  for (const subtask of task.subtasks) {
    total += sumOpenHours(subtask);
  }

  return total;
}

console.log(sumOpenHours(redesignTask));   // 7

Checking by hand: still open are "Visit two suppliers" (2 h) and "Paint and assemble" (5 h). Total, 7 h. "Measure and draw up the floor plan" (3 h) and "Request quotes" (2 h) are done.

  1. Walking the tree: flattening, counting and searching

The same scheme solves every operation over the tree. Only what happens at each node changes.

8.1 Flattening the tree into a list

/**
 * Returns a flat array with every task in the subtree,
 * adding its depth level to each one.
 */
function flattenTasks(task, level = 0) {
  const result = [{ id: task.id, title: task.title, level: level,
                    hours: task.estimatedHours, status: task.status }];

  for (const subtask of task.subtasks) {
    const children = flattenTasks(subtask, level + 1);
    for (const c of children) result.push(c);
  }

  return result;
}

const flat = flattenTasks(redesignTask);

for (const t of flat) {
  const indent = '  '.repeat(t.level);
  const badge = t.status === 'done' ? '✓' : '○';
  console.log(`${indent}${badge} [${t.id}] ${t.title}${t.hours > 0 ? ` — ${t.hours} h` : ''}`);
}

Output:

○ [1] Redesign the multipurpose room
  ✓ [11] Measure and draw up the floor plan — 3 h
  ○ [12] Choose the furniture
    ✓ [121] Request quotes — 2 h
    ○ [122] Visit two suppliers — 2 h
  ○ [13] Paint and assemble — 5 h

The level = 0 parameter with a default value (03-03) is a common pattern in recursion: whoever calls from outside omits it, and the function itself increments it on the way in.

8.2 Counting tasks and measuring the depth

function countTasks(task) {
  let total = 1;                                   // itself
  for (const sub of task.subtasks) total += countTasks(sub);
  return total;
}

function maxDepth(task) {
  if (task.subtasks.length === 0) return 1;        // explicit base case

  let deepest = 0;
  for (const sub of task.subtasks) {
    const d = maxDepth(sub);
    if (d > deepest) deepest = d;
  }
  return 1 + deepest;
}

console.log(countTasks(redesignTask));   // 6
console.log(maxDepth(redesignTask));     // 3

8.3 Searching for a task by identifier

/**
 * Searches the whole subtree for a task by id.
 * @returns {Object|null} the task found, or null
 */
function findById(task, id) {
  if (task.id === id) return task;                 // base case 1: found

  for (const sub of task.subtasks) {
    const found = findById(sub, id);
    if (found !== null) return found;              // stops as soon as it is found
  }

  return null;                                     // base case 2: not in this branch
}

const t = findById(redesignTask, 122);
console.log(t.title);                              // Visit two suppliers
console.log(findById(redesignTask, 999));          // null

The if (found !== null) return found; matters: without it, the function would carry on exploring useless branches after having found the result. It is the recursive equivalent of the break from break, continue and Nested Loops.

  1. Recursion versus iteration

Anything you can do with recursion you can do with loops, and the other way round. The question is which one suits each case.

Criterion Recursion Iteration (loops)
Readability with nested data Very high: the code mirrors the structure Low: you have to manage a stack by hand
Readability with flat data Worse: it adds noise High
Memory One stack entry per call Constant
Risk of overflow Real beyond ~10,000 levels None
Speed Slightly lower (each call costs) Slightly higher
Debugging Harder: the stack fills with identical frames Straightforward
State Implicit, in the parameters Explicit, in variables

Compare the two versions of the same task, flattening the tree. You have already seen the recursive one. The iterative one needs to manage its own stack:

function flattenIterative(root) {
  const result = [];
  const pending = [{ task: root, level: 0 }];       // explicit stack

  while (pending.length > 0) {
    const current = pending.pop();

    result.push({ id: current.task.id, title: current.task.title, level: current.level });

    // They are pushed in reverse order so they come out in the natural order
    for (let i = current.task.subtasks.length - 1; i >= 0; i--) {
      pending.push({ task: current.task.subtasks[i], level: current.level + 1 });
    }
  }

  return result;
}

console.log(flattenIterative(redesignTask).length);   // 6

It works, it cannot overflow the engine's stack and it is faster. But look at what it cost: a manual stack, a not-very-obvious reverse loop and a helper object to carry the level. That is the real choice: clarity versus control.

A practical day-to-day rule:

  • A nested structure of moderate depth (menu trees, comments, subtasks, JSON) → recursion.
  • A flat list, or potentially enormous depth (thousands of levels, large files) → iteration.

  1. Tail recursion and why JavaScript does not optimize it

A recursion is said to be a tail recursion when the recursive call is the last thing the function does: its result is returned as it stands, with no pending operations.

// NOT a tail call: on the way back there is still a multiplication to do
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1);       // ← the multiplication is left pending
}

// A tail call: the call is returned directly
function tailFactorial(n, accumulated = 1) {
  if (n <= 1) return accumulated;
  return tailFactorial(n - 1, n * accumulated);   // ← nothing pending
}

console.log(tailFactorial(5));   // 120

In theory, a tail recursion does not need to stack contexts: since there is nothing left to do on the way back, the engine could reuse the current frame. That is called tail call optimization (TCO) and it would turn the recursion into a loop, with constant memory.

The problem is that, in practice, JavaScript does not apply it:

Engine / environment Does it optimize tail calls?
The ES2015 specification Yes, it requires it
V8 (Chrome, Edge, Node.js) No (implemented and then removed)
SpiderMonkey (Firefox) No
JavaScriptCore (Safari) Yes, in strict mode

Check it for yourself:

function tailCount(n) {
  if (n <= 0) return 'end';
  return tailCount(n - 1);
}

try {
  console.log(tailCount(100000));
} catch (error) {
  console.error(`${error.name}: ${error.message}`);
}
// In Node.js: RangeError: Maximum call stack size exceeded

The practical conclusion: writing tail recursion in JavaScript does not protect you from overflow. It is a good stylistic habit and it works in other languages, but here, if the depth could get large, the solution is a loop.

  1. Memoization applied to Fibonacci

Back to the exponential Fibonacci from section 5. The problem was recomputing the same thing over and over, and you already have the tool to fix it: memoization with a closure from Scope and Closures.

/**
 * Returns a memoized version of Fibonacci.
 * The cache lives in the closure: private and persistent between calls.
 */
function createFibonacci() {
  const cache = {};          // private state
  let computations = 0;
  let hits = 0;

  function fib(n) {
    if (n < 0) throw new RangeError('n must be 0 or greater.');
    if (n <= 1) return n;

    if (n in cache) {
      hits++;
      return cache[n];
    }

    computations++;
    const result = fib(n - 1) + fib(n - 2);
    cache[n] = result;
    return result;
  }

  fib.stats = () => `${computations} computations, ${hits} hits`;
  return fib;
}

const fastFibonacci = createFibonacci();

let t = Date.now();
console.log(fastFibonacci(35), `${Date.now() - t} ms`);   // 9227465  ~0 ms
console.log(fastFibonacci.stats());                       // 34 computations, 33 hits

t = Date.now();
console.log(fastFibonacci(40), `${Date.now() - t} ms`);   // 102334155  ~0 ms
console.log(fastFibonacci(90));                           // 2880067194370816000

The comparison is devastating:

n Calls without memoization Computations with memoization Approximate time
10 177 9 ~0 ms in both
20 21,891 19 ~0 ms in both
30 2,692,537 29 ~30 ms → ~0 ms
35 29,860,703 34 ~300 ms → ~0 ms
40 ~331,000,000 39 several seconds → ~0 ms
50 unfeasible 49 — → ~0 ms

You go from exponential growth to linear growth. The cost is the cache's memory, which here is negligible, and the usual restriction: memoize pure functions only (03-03). fib(n) always gives the same thing for the same n, so it is safe.

Two warnings about the result of fastFibonacci(90): that number exceeds Number.MAX_SAFE_INTEGER and is therefore approximate. For large integers you have to use BigInt, the type you met in Variables and Data Types. And the depth is still linear: fastFibonacci(20000) would overflow the stack even with the cache working.

  1. When NOT to use recursion

There are three situations where recursion is the wrong answer:

Situation Why Alternative
Walking a flat list A loop is clearer, faster and safer for / for...of
Large or unbounded unknown depth RangeError in production, with real data Iteration with an explicit stack
A simple calculation with an accumulator Recursion adds noise and contributes nothing A loop with an accumulator variable
// ✗ Unnecessary recursion over a flat list
function sumHoursRecursive(hours, i = 0) {
  if (i >= hours.length) return 0;
  return hours[i] + sumHoursRecursive(hours, i + 1);
}

// ✓ A loop: clearer, with no stack risk
function sumHours(hours) {
  let total = 0;
  for (const h of hours) total += h;
  return total;
}

console.log(sumHours([12, 6, 14, 3, 8, 5]));   // 48

And a security warning that matters more than it looks: if the nested data comes from outside (an API, a file the user uploads), the depth is controlled by whoever sends the data. A malicious tree 100,000 levels deep would take your recursive function down. In those cases, either you iterate, or you set an explicit limit:

function sumTotalHours(task, depth = 0) {
  if (depth > 50) {
    throw new RangeError('The subtask structure is too deep (maximum 50 levels).');
  }

  let total = task.estimatedHours;
  for (const sub of task.subtasks) {
    total += sumTotalHours(sub, depth + 1);
  }
  return total;
}

Common Mistakes and Tips

1. Forgetting the base case. It is mistake number one and it always produces RangeError: Maximum call stack size exceeded.

2. An unreachable base case. Use <= instead of === when the argument could skip over it (decimals, subtracting more than one).

3. Not shrinking the problem.

function bad(task) {
  return sumTotalHours(task);   // ✗ same argument, infinite recursion
}

4. Forgetting the return in front of the recursive call.

function badFind(task, id) {
  if (task.id === id) return task;
  for (const sub of task.subtasks) {
    badFind(sub, id);        // ✗ the result is thrown away
  }
  return null;               // always null
}

It is a silent failure: no error, it simply never finds anything.

5. Confusing a for inside a recursion with a nested loop. The for walks this node's children; the recursion goes down a level. They are different axes.

6. Memoizing an impure function. You have known this since 03-04: the cache would hand back stale results.

7. Relying on tail recursion. It is not optimized in most engines.

8. Tip: draw the call tree for three or four levels. Nearly every recursion bug is obvious at a glance in the drawing and invisible when reading the code.

9. Tip: add an indented console.log when debugging.

function sumWithTrace(task, level = 0) {
  const indent = '  '.repeat(level);
  console.log(`${indent}→ entering ${task.title}`);

  let total = task.estimatedHours;
  for (const sub of task.subtasks) total += sumWithTrace(sub, level + 1);

  console.log(`${indent}← leaving ${task.title} with ${total} h`);
  return total;
}

Seeing the way down and the way up indented makes any recursion comprehensible. In Debugging JavaScript you will see how to do the same thing with breakpoints and the live stack.

Exercises

Exercise 1 — Operations over the subtask tree

Over redesignTask, write three recursive functions:

  • countByStatus(task, status): how many tasks in the subtree are in that status.
  • titlesForAssignee(task, name): an array with the titles of the tasks assigned to that person, at any depth.
  • isComplete(task): true if the task and all its subtasks are in status 'done'.

Exercise 2 — The path to a task

Write pathTo(task, id) returning an array with the titles running from the root down to the task with that identifier, or null if it does not exist. For example, for id 121 it must return ['Redesign the multipurpose room', 'Choose the furniture', 'Request quotes'].

Exercise 3 — From recursive to iterative

The maxDepth function from section 8.2 is recursive. Rewrite it iteratively using an explicit stack, check that it gives the same result (3) and explain in which specific case you would prefer each version.

Solutions

Exercise 1

function countByStatus(task, status) {
  let total = task.status === status ? 1 : 0;
  for (const sub of task.subtasks) {
    total += countByStatus(sub, status);
  }
  return total;
}

function titlesForAssignee(task, name) {
  const found = [];

  if (task.assignee === name) found.push(task.title);

  for (const sub of task.subtasks) {
    const children = titlesForAssignee(sub, name);
    for (const c of children) found.push(c);
  }

  return found;
}

function isComplete(task) {
  if (task.status !== 'done') return false;          // guard: this one is not done

  for (const sub of task.subtasks) {
    if (!isComplete(sub)) return false;              // stops at the first one that fails
  }

  return true;
}

console.log(countByStatus(redesignTask, 'done'));          // 2
console.log(countByStatus(redesignTask, 'pending'));       // 2
console.log(countByStatus(redesignTask, 'in-progress'));   // 2

console.log(titlesForAssignee(redesignTask, 'Marta'));
// [ 'Choose the furniture', 'Request quotes', 'Visit two suppliers' ]

console.log(isComplete(redesignTask));                     // false
console.log(isComplete(findById(redesignTask, 11)));       // true

Comment: all three follow the same scheme —do something with this node, then with each child— and only the operation changes. isComplete also builds in an early exit: as soon as it finds an incomplete subtask it stops looking at the rest, just like myEvery in Higher-Order Functions.

Exercise 2

function pathTo(task, id) {
  if (task.id === id) return [task.title];           // base case: this is the one

  for (const sub of task.subtasks) {
    const childPath = pathTo(sub, id);               // the leap of faith
    if (childPath !== null) {
      return [task.title, ...childPath];             // put this level at the front
    }
  }

  return null;                                       // not in this branch
}

console.log(pathTo(redesignTask, 121));
// [ 'Redesign the multipurpose room', 'Choose the furniture', 'Request quotes' ]

console.log(pathTo(redesignTask, 13));
// [ 'Redesign the multipurpose room', 'Paint and assemble' ]

console.log(pathTo(redesignTask, 999));   // null

// And to display it as a breadcrumb trail:
const path = pathTo(redesignTask, 122);
console.log(path.join(' › '));
// Redesign the multipurpose room › Choose the furniture › Visit two suppliers

Comment: the key is the line return [task.title, ...childPath];. The ... is the spread operator, which unfolds the elements of childPath inside the new array; you will study it formally in Object Destructuring, Spread and Rest. Without it, you would have to build the array with a loop. Notice too that the path is assembled on the way up: each level adds its title in front of whatever the level below hands back.

Exercise 3

function maxDepthIterative(root) {
  let deepest = 0;
  const pending = [{ task: root, level: 1 }];

  while (pending.length > 0) {
    const current = pending.pop();

    if (current.level > deepest) deepest = current.level;

    for (const sub of current.task.subtasks) {
      pending.push({ task: sub, level: current.level + 1 });
    }
  }

  return deepest;
}

console.log(maxDepth(redesignTask));            // 3
console.log(maxDepthIterative(redesignTask));   // 3

When to prefer each one:

Version When
Recursive Subtask trees created by the Taller Nómada team: two or three levels deep, and the code reads like the definition of the problem
Iterative Data imported from an external API, where the depth is not under your control and a very deep tree would take the application down

Notice one subtle difference as well: the recursive version computes the depth on the way up (1 + the maximum of the children), while the iterative one carries it on the way down, storing the level alongside each pending node. It is the same calculation seen from the other side.

Conclusion

With this lesson you close Module 3. You now know that a recursive function calls itself with a smaller problem, that it always needs a reachable base case and a recursive case that genuinely shrinks the problem, and that each call stacks a context that is only released on the way back up: that is why a deep recursion produces the RangeError you met in 03-05. You have practiced with factorial and fibonacci, and you have seen first-hand how the naive Fibonacci goes from 177 calls at n = 10 to nearly thirty million at n = 35, and how the closure-based memoization from 03-04 brings it back down to thirty-four computations.

Above all, you have seen why recursion exists. When Marta asked for a task to be broken down into subtasks, and those into others, loops stopped working: you cannot write a nested for for a depth you do not know. sumTotalHours, flattenTasks, countTasks, maxDepth, findById and pathTo solve that tree with the same six-line scheme, because the structure of the code mirrors the structure of the data. And you know its limits: the recursion-versus-iteration table, the tail recursion JavaScript does not optimize, the depth limit for data coming from outside and the three situations in which a loop is simply the right answer.

Look at what you have gained over these seven lessons. You started with blocks of code copied four times and you finish with priorityWeight(), isOverdue(), describeTask(), createTask(), summarizeWorkload(), createIdGenerator(), createTaskStore(), a configurable reporting engine and a recursive walk over subtasks. You know how to define and call functions, how to write them as values and as arrows, how to design their parameters and their return values, how to control where each variable lives, how to lock state inside a closure, how to understand what the engine prepares before running anything, how to pass functions to other functions and how to make a function call itself. That is, by a long way, the biggest leap of the course so far.

But notice the price you have been paying without complaining. This whole module has worked with loose variables and parallel arrays: titles[i], assignees[i], priorities[i], statuses[i], hours[i], dueDates[i]. You have had to filter indices instead of tasks, pass eight arguments to describeTask, drag six arrays into every function and trust that none of them ever gets out of alignment. And the moment a real structure showed up —the subtask tree— there was no option but to use nested objects, with a note saying that would come later. That "later" is now. In Module 4: Objects and Arrays you will stop simulating the data model and write it properly: each task will be an object with its nine fields, the backlog will be an array of objects, and every function from this module will be rewritten with clean signatures like describeTask(task, today). Start with Introduction to Objects, and the first relief will arrive on the very first page.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved