01 / The idea
Recursion is a fair way to total an outline.
You’re building the course editor for a learning platform. Every section shows its reading
time, including everything nested inside it. totalMinutes adds a section’s own minutes
to each child’s total, and gets each child’s total by calling itself. The code has the same shape
as the outline, and for courses people write by hand it’s the right code.
Read the first totalTypeScript · the version this lesson starts from
// The first version: a section's total is its own minutes plus each child's total.
export function totalMinutes(section: Section, trace?: CallTrace): number {
trace?.enter(section);
let total = section.minutes;
for (const child of section.children) {
const childTotal = totalMinutes(child, trace);
total += childTotal;
}
trace?.leave(section, total);
return total;
} The optional trace lets this page record each call; it doesn’t change the total.
Go’s version is the same recursion without it. Both languages meet again at the frames version
in section 02.
Then two things happen. Someone moves let total to the top of the module so a
label can show progress, and the course that totaled 12 now totals 8. And a customer imports
a wiki export nested 100,000 sections deep, and the editor throws RangeError: Maximum call stack size exceeded.
Every call gets a frame of its own: its own local variables, and the place it resumes when the call it made returns. Frames pile up as calls nest and come off in reverse order, so the number open at once follows how deep the calls nest, not how many calls there are. That pile is finite, and when the data decides the depth, you can keep the frames yourself. MDN defines the unit plainly: “An execution context, also known generally as a stack frame, is the smallest unit of execution.”
Section 05 builds a recursive outline component and an imported outline of any depth, in React and Svelte.
02 / See the shape
Write the frames down when the data decides the depth.
The basic form keeps the frames in an array: each one holds a section’s total and the next child to visit. In the wild uses the same frames to fill in every section’s subtotal. At the call site runs both versions on the course and the frames version on a 100,000-deep chain.
Both languages produce the same results.
The frames written down. Each frame keeps what a recursive call keeps: its own total, and the next child to visit when it resumes.
// The same work with the frames written down. Each frame keeps what a recursive call keeps:
// its own total, and where it resumes (the next child to visit).
type Frame = { section: Section; total: number; next: number };
export function totalWithFrames(root: Section): number {
const frames: Frame[] = [{ section: root, total: root.minutes, next: 0 }];
let result = 0;
while (frames.length > 0) {
const frame = frames[frames.length - 1];
const child = frame.section.children[frame.next];
if (child) {
frame.next += 1;
frames.push({ section: child, total: child.minutes, next: 0 });
} else {
frames.pop();
const caller = frames.at(-1);
if (caller) caller.total += frame.total;
else result = frame.total;
}
}
return result;
} // frame keeps what a recursive call keeps: its own total, and where it resumes.
type frame struct {
section *Section
total int
next int
}
// TotalWithFrames does the same work with the frames written down.
func TotalWithFrames(root *Section) int {
frames := []frame{{section: root, total: root.Minutes}}
result := 0
for len(frames) > 0 {
top := &frames[len(frames)-1]
if top.next < len(top.section.Children) {
child := &top.section.Children[top.next]
top.next++
frames = append(frames, frame{section: child, total: child.Minutes})
continue
}
done := frames[len(frames)-1]
frames = frames[:len(frames)-1]
if len(frames) > 0 {
frames[len(frames)-1].total += done.total
} else {
result = done.total
}
}
return result
} Reading the TypeScriptAn array as the stack
frames[frames.length - 1] is the open call. Pushing a frame is making a
call; popping one and adding its total to frames.at(-1) is returning to the
caller. next is the resume point that a recursive call keeps for you.
MDN’s execution model describes the real thing the same way: calling a function creates a new frame for its parameters and locals, and returning pops it so execution continues in the previous frame.
Reading the GoGoroutine stacks grow, up to a limit
The frames hold *Section pointers, so pushing a frame doesn’t copy a
subtree. OutlineRows declares its frame type inside the function because nothing else
needs it.
Go grows a goroutine’s stack as calls nest, which is why its recursive version finishes
the 100,000-deep chain in the lesson’s tests. It still has a ceiling: runtime/debug.SetMaxStack starts at 1 GB on 64-bit systems, and “if any goroutine
exceeds this limit while growing its stack, the program crashes.”
03 / Follow the calls
Watch the calls open and close.
Five steps, recorded from the recursive total. Each box is an open call with its own total, newest on top, beside the outline it’s working through. Before each step, guess which call is running and what each total holds.
In Try it, pick a wide, nested, or chained outline and step through every call.
Which calls are still open?
Each call gets its own total. Open calls, top first: Setup with total 3, running; Course with total 2, waiting on Setup. Course calls Setup, which starts its own total at 3. Course started at 2 and is waiting on Setup. Setup is a separate call with its own total, 3.
Each call gets its own total.
totalMinutes(Course) calls totalMinutes(Setup). Course keeps 2 and waits; Setup starts its own total at 3.
Reduced motion: choose a scene to see its completed state.
Read this scene
totalMinutes(Course) calls totalMinutes(Setup). Course keeps 2 and waits; Setup starts its own total at 3.
Each call gets its own total. Open calls, top first: Setup with total 3, running; Course with total 2, waiting on Setup. Course calls Setup, which starts its own total at 3. Course started at 2 and is waiting on Setup. Setup is a separate call with its own total, 3.
Watch restarts when you return. Step through keeps your selected step. Try it starts at the end of the nested outline each time you open it.
What frames buy you
Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.
- Each call’s own state
- Course, Practice, and Check hold 5, 5, and 2 at the same moment.
- Resuming where it left off
- After Setup returns 3, Course carries on from the line after the call.
- Code shaped like the data
totalMinutescalls itself once per child, just as the outline nests.- Depth you can predict
- Wide, nested, and chained outlines open at most 2, 3, and 4 calls, all totaling 12.
- Depth you control
totalWithFramesfinishes a 100,000-deep chain, because its depth is an array’s length.
The review words are call stack, stack frame, base case for the section with no children, unwinding for the returns, and stack overflow for running out. Section 08 covers what they cost.
04 / Try a decision
A total that moved out of its function.
A progress label needed to read the running total, so total moved to module
scope. The code is in shared-total.ts, and the lesson’s tests pin what happens.
05 / Give it a real job
An outline whose depth someone else decided.
In the real editor, courses can be imported from other tools, and generated exports can nest far deeper than anyone would type. The imported outline view shows every section with its subtotal, lets people collapse sections, and has to work however deep the file goes.
Depth set by the file
Nothing in the editor limits how deep it nests.
One flat list
Each row carries its depth, so the markup doesn’t nest either.
Hidden, still counted
Their children get no rows but stay in the subtotal.
The example leaves out importing the file, cycles in the data, and virtualizing a list with tens of thousands of rows, which a real import would also need.
Build UIs?Every component that renders its own children is recursion, and one day the data it renders comes from somewhere you don’t control.
Where it already is in your components
The textbook outline is a component that renders itself for each child. In React, OutlineItem returns more OutlineItems. In Svelte 5 the component
imports itself; the docs call <svelte:self> obsolete, “as components can
import themselves.” Either way, one level of nesting is one more component inside the last.
When you have to own it
Now it’s the imported outline. outlineRows keeps its own frames and returns a flat
list, and the component renders that list with an indent per depth. Collapsing a section changes
which rows appear, not the totals.
The walk still runs synchronously, and MDN notes that while a job runs “the web application is unable to process user interactions like click or scroll.” Both versions only recompute when the outline or the collapsed set changes.
export interface OutlineSection {
id: string;
title: string;
minutes: number;
children: readonly OutlineSection[];
}
export type OutlineRow = {
id: string;
title: string;
depth: number;
total: number;
hasChildren: boolean;
};
// Rows for every visible section, with subtotals that still count collapsed children.
// It keeps its own frames instead of recursing, so an imported outline of any depth fits.
export function outlineRows(
root: OutlineSection,
collapsed: ReadonlySet<string> = new Set()
): OutlineRow[] {
const rows: OutlineRow[] = [rowFor(root, 0)];
const frames = [
{ section: root, row: 0, total: root.minutes, next: 0, showsChildren: !collapsed.has(root.id) }
];
while (frames.length > 0) {
const frame = frames[frames.length - 1];
const child = frame.section.children[frame.next];
if (child) {
frame.next += 1;
// A hidden child still adds to the total; it just gets no row.
const row = frame.showsChildren ? rows.push(rowFor(child, frames.length)) - 1 : -1;
frames.push({
section: child,
row,
total: child.minutes,
next: 0,
showsChildren: row >= 0 && !collapsed.has(child.id)
});
} else {
frames.pop();
if (frame.row >= 0) rows[frame.row].total = frame.total;
const caller = frames.at(-1);
if (caller) caller.total += frame.total;
}
}
return rows;
}
function rowFor(section: OutlineSection, depth: number): OutlineRow {
return {
id: section.id,
title: section.title,
depth,
total: section.minutes,
hasChildren: section.children.length > 0
};
}
A recursive outline item that renders itself for each child section: a React component returning itself, and a Svelte component importing itself.
import type { OutlineSection } from './outline-rows';
// A component that renders itself for each child: one render call per level of nesting.
export function OutlineItem({ section }: { section: OutlineSection }) {
return (
<li>
{section.title} <small>{section.minutes} min</small>
{section.children.length > 0 && (
<ul>
{section.children.map((child) => (
<OutlineItem key={child.id} section={child} />
))}
</ul>
)}
</li>
);
}
06 / Recognize it elsewhere
Anywhere work waits on work it started.
You’ve met all of these. For each one, find what a frame holds and what decides the depth.
| Where you’ve seen it | What each frame holds | What decides the depth |
|---|---|---|
| A recursive tree component | One section’s props | How deep the data nests |
| A stack trace in an error | One line per open call | How deep the calls were when it threw |
| A folder walk that recurses into subfolders | One folder’s listing and its place in it | How deep the folders go |
Middleware where each layer calls next() | A layer waiting for the rest to finish | How many layers there are |
| A parser for nested brackets | The bracket it’s inside | How deeply the input nests |
Before trusting a recursive function, find who decides its depth. If it’s the input, decide what happens when the input goes deeper than you expected.
07 / Already in your toolbox
Your platform already documents its frames and their limits.
Three places to look. For each one, find what a frame holds and what happens at the limit.
MDN · JavaScript execution model
Frames, what a frame tracks, the stack they form, and run-to-completion, with a worked example of calls pushing and returns popping.
Read the reference ↗MDN · Too much recursion
The error each engine throws when the stack runs out: RangeError: Maximum call stack size exceeded in Chrome and Safari, InternalError: too much recursion in Firefox.
Go · runtime/debug.SetMaxStack
How large a goroutine’s stack may grow, the 1 GB and 250 MB defaults, and the crash when a goroutine goes past it.
Read the reference ↗A useful counterexample: a course your own team writesWhen recursion is the better code
A course authored in the editor, a handful of levels deep, doesn’t need frames written down. The recursive total says what it means in six lines.
08 / The parts to watch
Frames are cheap until the data decides how many.
These are the places it still goes wrong.
A variable outside the function isn’t per call
Move a local to module scope and every call shares it. The course returns 8, and a section with no children still looks right.
Imported data decides your depth
A recursive walk over a file, a response, or a user’s tree nests as deep as that data does. Either write the frames down or reject input past a depth you choose.
The limit depends on the engine
In Node 22 the recursive totalMinutes runs out of stack after a few thousand levels,
about 4,300 when we checked, so an imported outline doesn’t have to be strange to reach it.
Browsers, platforms, and the size of each frame all change where the limit is, and even the
error’s name differs.
Go’s larger stack is still a limit
A goroutine that passes its maximum stack crashes the program. There’s no error value to handle.
Frames don’t fix cycles
An outline that contains itself loops forever in both versions. Track visited sections if the data can repeat.
No recursion isn’t the same as not blocking
outlineRows can’t overflow, but a walk over a huge outline still holds up clicks
and scrolling until it finishes.
09 / Make the call
What would you have to change tomorrow?
Give both totals a plausible change and follow the work it creates.
| The change | Recursion | Explicit frames |
|---|---|---|
| Courses written in the editor | Short and clear. | More code for the same result. |
| Import outlines from other tools | Can run out of stack. | Finishes at any depth. |
| Show every section’s subtotal | Return values carry it. | Each frame remembers its row. |
| Debug a wrong total | The stack trace shows the path. | Inspect the frames array. |
| The data can contain a cycle | Never finishes. | Never finishes. |
Write the frames down when input you don’t control decides the depth. An import is the moment.
Keep recursion when people decide the depth and it stays small.
The question I’d leave beside the code is: what does each open call keep, and what decides how many are open at once?
10 / Take the idea with you
Explain the 8 without saying “stack frame.”
“Every call was writing to the same total, so each section’s children overwrote it before the section added them. Keeping the total inside the function gave every call its own again.” In a review, the words are stack frame, call stack, recursion, and stack overflow.
Before moving on, jot down why the course totaled 8, why the import threw, and one recursive function in your own code whose depth comes from its input.
Connections to follow nextRelated lessons
- Stack is the data structure underneath: last in, first out, the same order calls return in.
- Closures and captured state shows functions keeping variables after their call has returned.
- CPU and memory profiling reads call stacks like these to find where time goes.